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
+37
View File
@@ -6,3 +6,40 @@ JWT_SECRET=your-secure-jwt-secret-here
# Directory containing docker-compose files
COMPOSE_DIR=/path/to/your/compose/files
# ─── SSO / LDAP Configuration (Team Pro) ───────────────────────────
# LDAP / Active Directory
SSO_LDAP_ENABLED=false
SSO_LDAP_URL=ldap://ldap.example.com:389
SSO_LDAP_BIND_DN=cn=readonly,dc=example,dc=com
SSO_LDAP_BIND_PASSWORD=
SSO_LDAP_SEARCH_BASE=ou=users,dc=example,dc=com
SSO_LDAP_SEARCH_FILTER=(uid={{username}})
SSO_LDAP_ADMIN_GROUP_DN=
SSO_LDAP_DEFAULT_ROLE=viewer
SSO_LDAP_TLS_REJECT_UNAUTHORIZED=true
# Google OIDC
SSO_OIDC_GOOGLE_ENABLED=false
SSO_OIDC_GOOGLE_CLIENT_ID=
SSO_OIDC_GOOGLE_CLIENT_SECRET=
# GitHub OAuth
SSO_OIDC_GITHUB_ENABLED=false
SSO_OIDC_GITHUB_CLIENT_ID=
SSO_OIDC_GITHUB_CLIENT_SECRET=
# Okta OIDC
SSO_OIDC_OKTA_ENABLED=false
SSO_OIDC_OKTA_ISSUER_URL=
SSO_OIDC_OKTA_CLIENT_ID=
SSO_OIDC_OKTA_CLIENT_SECRET=
# Role mapping (shared across OIDC providers)
SSO_OIDC_ADMIN_CLAIM=groups
SSO_OIDC_ADMIN_CLAIM_VALUE=sencho-admins
SSO_DEFAULT_ROLE=viewer
# External base URL for OAuth callback URLs (required behind reverse proxy)
SSO_CALLBACK_URL=
+1
View File
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
* **sso:** Team Pro SSO/LDAP integration — authenticate via LDAP/Active Directory, Google, GitHub, or Okta alongside existing password login. Auto-provisions new users on first SSO login with configurable role mapping from identity provider groups/claims. All provider credentials encrypted at rest. Configurable via environment variables or Settings UI. OIDC flows use PKCE and state-based CSRF protection. Added `trust proxy` for correct behavior behind reverse proxies.
* **audit-log:** Team Pro audit logging — records all mutating API actions (deploy, stop, delete, settings changes, user CRUD) with user attribution, timestamp, HTTP method, status code, and node context. Searchable timeline UI with filtering by username and method. 90-day retention with automatic cleanup.
* **security:** encryption at rest for sensitive database values — node API tokens are now encrypted with AES-256-GCM using a per-instance key stored outside the database. Existing plaintext tokens are automatically migrated on startup.
+131
View File
@@ -27,7 +27,9 @@
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.3",
"ldapts": "^4.2.6",
"node-pty": "^1.1.0",
"openid-client": "^5.7.1",
"systeminformation": "^5.31.1",
"ws": "^8.19.0",
"yaml": "^2.8.2",
@@ -794,6 +796,15 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/asn1": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@types/asn1/-/asn1-0.2.4.tgz",
"integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
@@ -1081,6 +1092,12 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -3120,6 +3137,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3199,6 +3225,54 @@
"json-buffer": "3.0.1"
}
},
"node_modules/ldapts": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-4.2.6.tgz",
"integrity": "sha512-r1eOj2PtTJi+9aZxLirktoHntuYXlbQD9ZXCjiZmJx0VBQtBcWc+rueqABuh/AxMcFHNPDSJLJAXxoj5VevTwQ==",
"license": "MIT",
"dependencies": {
"@types/asn1": ">=0.2.0",
"@types/node": ">=14",
"@types/uuid": ">=9",
"asn1": "~0.2.6",
"debug": "~4.3.4",
"strict-event-emitter-types": "~2.0.0",
"uuid": "~9.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/ldapts/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ldapts/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3556,6 +3630,18 @@
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -3857,6 +3943,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -3880,6 +3975,15 @@
],
"license": "MIT"
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -3901,6 +4005,21 @@
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4612,6 +4731,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/strict-event-emitter-types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
"license": "ISC"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -5542,6 +5667,12 @@
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+2
View File
@@ -51,7 +51,9 @@
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.3",
"ldapts": "^4.2.6",
"node-pty": "^1.1.0",
"openid-client": "^5.7.1",
"systeminformation": "^5.31.1",
"ws": "^8.19.0",
"yaml": "^2.8.2",
+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 };
}
}
}
+3 -1
View File
@@ -74,7 +74,8 @@
"pages": [
"getting-started/introduction",
"getting-started/quickstart",
"getting-started/configuration"
"getting-started/configuration",
"getting-started/sso-quickstart"
]
},
{
@@ -96,6 +97,7 @@
"features/atomic-deployments",
"features/fleet-backups",
"features/audit-log",
"features/sso",
"features/licensing"
]
},
+8
View File
@@ -49,6 +49,14 @@ Admins can manage accounts in **Settings → Users**. From there you can:
- **Edit** an existing user's password or role
- **Delete** a user account
## SSO auto-provisioning
With a Team Pro license, users can also be created automatically when they log in via SSO (LDAP, Google, GitHub, or Okta). SSO users appear in the Users list alongside local accounts. They are assigned a role based on identity provider group membership or claim mapping.
SSO users cannot log in with a password — they must always authenticate through their identity provider.
To set up identity provider authentication, see [SSO Authentication →](/features/sso).
## Migration from single-admin setup
When you upgrade to Sencho Pro, your existing single-admin credentials are automatically migrated to the new users table. No manual action is required - your login continues to work as before, and your account is assigned the Admin role.
+193
View File
@@ -0,0 +1,193 @@
---
title: SSO & LDAP Authentication
description: Authenticate with your existing identity provider — LDAP, Google, GitHub, or Okta.
---
<Note>
SSO requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature.
</Note>
Sencho Team Pro lets your team sign in using existing identity providers instead of managing separate credentials. SSO works **alongside** password authentication — it does not replace it.
## Supported providers
| Provider | Protocol | Notes |
|----------|----------|-------|
| **LDAP / Active Directory** | LDAP bind + search | Works with OpenLDAP, Active Directory, FreeIPA, and any LDAPv3 server |
| **Google** | OpenID Connect | Google Workspace or personal Google accounts |
| **GitHub** | OAuth 2.0 | GitHub personal accounts and GitHub orgs |
| **Okta** | OpenID Connect | Any Okta org or Okta-compatible IdP |
## How it works
### LDAP flow
1. User enters their directory username and password on the Sencho login page
2. Sencho binds to LDAP with a service account, searches for the user, then verifies their password
3. If this is their first login, a Sencho account is automatically created
4. Sencho issues a JWT and the user is logged in — identical to a password login
### OIDC / OAuth flow (Google, GitHub, Okta)
1. User clicks the provider button on the login page (e.g., "Sign in with Google")
2. Browser redirects to the identity provider for authentication
3. After granting consent, the provider redirects back to Sencho with an authorization code
4. Sencho exchanges the code for tokens, verifies the ID token, and reads user information
5. If this is their first login, a Sencho account is automatically created
6. Sencho issues a JWT and redirects to the dashboard
All OIDC flows use **PKCE** (Proof Key for Code Exchange) and a **state parameter** for CSRF protection.
## Auto-provisioning
When a user logs in via SSO for the first time, Sencho automatically creates a local account:
- **Username** is derived from their identity provider profile (display name, email prefix, or login handle)
- **Role** is assigned based on [role mapping](#role-mapping) — defaults to Viewer if no mapping matches
- **Password** is set to an unusable placeholder — SSO users cannot log in with a password
- **Seat limits** from your license apply. If admin seats are full, the user is downgraded to Viewer. If all seats are full, login is denied with a clear error message.
On subsequent logins, the existing account is reused. The user's email is updated if it changed at the provider.
## Role mapping
### LDAP group mapping
Set the **Admin Group DN** to a group in your directory. Members of that group get the Admin role; everyone else gets the default role (Viewer).
Example: If your admin group is `cn=sencho-admins,ou=groups,dc=example,dc=com`, set that as the Admin Group DN. Users who are a `member` of that group will be provisioned as Admin.
### OIDC claim mapping
For OIDC providers, configure two fields:
| Field | Description | Example |
|-------|-------------|---------|
| **Admin Claim** | The JWT claim name that contains role information | `groups` |
| **Admin Claim Value** | The value within that claim that grants Admin | `sencho-admins` |
If the user's ID token contains a `groups` claim with the value `sencho-admins`, they get Admin. Otherwise, they get the default role.
<Note>
Not all providers include a `groups` claim by default. You may need to configure custom claims in your identity provider's admin console.
</Note>
## Configuration
SSO can be configured two ways:
1. **Settings UI** — Go to **Settings → SSO** in the Sencho dashboard. Enable providers, enter credentials, and test connections from the UI. Changes take effect immediately without restarting.
2. **Environment variables** — Set `SSO_*` variables in your Docker Compose file. These seed the database on first boot. After that, the database configuration is authoritative.
### Via Settings UI
Admins can manage SSO providers in **Settings → SSO**. Each provider has:
- An **enable/disable** toggle
- Provider-specific configuration fields
- A **Test Connection** button to verify connectivity before saving
<Frame>
<img src="/images/sso/sso-settings.png" alt="SSO settings panel showing all four identity providers" />
</Frame>
Expand a provider card to configure it. Here's the LDAP configuration form:
<Frame>
<img src="/images/sso/sso-settings-ldap.png" alt="LDAP configuration form with server URL, bind DN, search base, and role mapping" />
</Frame>
And an OIDC provider (Google) configuration form:
<Frame>
<img src="/images/sso/sso-settings-oidc.png" alt="Google OIDC configuration form with client ID, client secret, and role claim mapping" />
</Frame>
### Via environment variables
Environment variables are useful for initial deployment or infrastructure-as-code workflows. They seed the SSO configuration on first startup. After that, changes made in the Settings UI take precedence.
## SSO environment variables reference
### LDAP
| Variable | Default | Description |
|----------|---------|-------------|
| `SSO_LDAP_ENABLED` | `false` | Enable LDAP authentication |
| `SSO_LDAP_URL` | — | LDAP server URL (e.g., `ldap://ldap.example.com:389` or `ldaps://ldap.example.com:636`) |
| `SSO_LDAP_BIND_DN` | — | Service account DN for searching users |
| `SSO_LDAP_BIND_PASSWORD` | — | Service account password (encrypted at rest in the database) |
| `SSO_LDAP_SEARCH_BASE` | — | Base DN for user searches (e.g., `ou=users,dc=example,dc=com`) |
| `SSO_LDAP_SEARCH_FILTER` | `(uid={{username}})` | LDAP filter template. Use `(sAMAccountName={{username}})` for Active Directory |
| `SSO_LDAP_ADMIN_GROUP_DN` | — | DN of the group whose members receive the Admin role |
| `SSO_LDAP_DEFAULT_ROLE` | `viewer` | Role assigned to LDAP users not in the admin group |
| `SSO_LDAP_TLS_REJECT_UNAUTHORIZED` | `true` | Whether to verify the LDAP server's TLS certificate |
### Google OIDC
| Variable | Default | Description |
|----------|---------|-------------|
| `SSO_OIDC_GOOGLE_ENABLED` | `false` | Enable Google SSO |
| `SSO_OIDC_GOOGLE_CLIENT_ID` | — | OAuth client ID from Google Cloud Console |
| `SSO_OIDC_GOOGLE_CLIENT_SECRET` | — | OAuth client secret (encrypted at rest) |
### GitHub OAuth
| Variable | Default | Description |
|----------|---------|-------------|
| `SSO_OIDC_GITHUB_ENABLED` | `false` | Enable GitHub SSO |
| `SSO_OIDC_GITHUB_CLIENT_ID` | — | OAuth app client ID from GitHub Developer Settings |
| `SSO_OIDC_GITHUB_CLIENT_SECRET` | — | OAuth app client secret (encrypted at rest) |
### Okta OIDC
| Variable | Default | Description |
|----------|---------|-------------|
| `SSO_OIDC_OKTA_ENABLED` | `false` | Enable Okta SSO |
| `SSO_OIDC_OKTA_ISSUER_URL` | — | Okta issuer URL (e.g., `https://dev-123456.okta.com`) |
| `SSO_OIDC_OKTA_CLIENT_ID` | — | Okta application client ID |
| `SSO_OIDC_OKTA_CLIENT_SECRET` | — | Okta client secret (encrypted at rest) |
### General
| Variable | Default | Description |
|----------|---------|-------------|
| `SSO_OIDC_ADMIN_CLAIM` | `groups` | JWT claim name inspected for Admin role mapping |
| `SSO_OIDC_ADMIN_CLAIM_VALUE` | `sencho-admins` | Value in the admin claim that maps to the Admin role |
| `SSO_DEFAULT_ROLE` | `viewer` | Default role for all SSO users when no mapping matches |
| `SSO_CALLBACK_URL` | auto-detect | External base URL for OAuth callback URLs (see below) |
## Reverse proxy and callback URLs
<Warning>
If Sencho is behind a reverse proxy (nginx, Traefik, Caddy), you **must** set `SSO_CALLBACK_URL` to your external URL. Otherwise, OAuth callbacks will fail.
</Warning>
Set `SSO_CALLBACK_URL` to the URL users access Sencho from — for example, `https://sencho.example.com`. Sencho uses this to construct the OAuth redirect URI that your identity provider calls back to.
If not set, Sencho auto-detects the URL from the request's `Host` header and protocol, which works for direct access but fails behind proxies that rewrite the host.
## Security
- **PKCE** — All OIDC flows use `code_challenge_method=S256` to prevent authorization code interception
- **State parameter** — A cryptographic random value protects against CSRF attacks on the OAuth callback
- **Encrypted secrets** — LDAP bind passwords and OIDC client secrets are encrypted at rest with AES-256-GCM
- **No local password** — SSO users are created with an unusable password hash. They cannot bypass SSO by using the password login form
- **Admin-only configuration** — Only Team Pro administrators can enable or configure SSO providers
## Troubleshooting
### LDAP connection refused
Verify the LDAP server is reachable from the Sencho container. If LDAP is on the host machine, use `host.docker.internal` (Docker Desktop) or the host's LAN IP address — not `localhost`.
### TLS certificate errors
If your LDAP server uses a self-signed certificate, set `SSO_LDAP_TLS_REJECT_UNAUTHORIZED=false`. For production, install a trusted certificate instead.
### OAuth callback URL mismatch
The redirect URI registered in your identity provider must exactly match what Sencho sends. Check:
1. `SSO_CALLBACK_URL` is set to your external URL (e.g., `https://sencho.example.com`)
2. The callback URL in your provider's settings is `https://sencho.example.com/api/auth/sso/oidc/<provider>/callback`
3. Protocol matches — don't mix `http` and `https`
### SSO buttons not appearing on login page
SSO providers only appear on the login page when they are both **configured** and **enabled**. Check Settings → SSO to verify the provider is active.
+14
View File
@@ -27,6 +27,20 @@ When you point `COMPOSE_DIR` at a directory, Sencho expects each stack to live i
| `DATA_DIR` | `/app/data` | Directory where Sencho stores its SQLite database, node registry, and cached metrics. |
| `NODE_ENV` | `production` | Set automatically in the Docker image. Only change this for local development. |
## SSO environment variables
If you use SSO (Team Pro), configure your identity providers via environment variables:
| Variable | Description |
|----------|-------------|
| `SSO_LDAP_ENABLED` | Enable LDAP/AD authentication |
| `SSO_OIDC_GOOGLE_ENABLED` | Enable Google SSO |
| `SSO_OIDC_GITHUB_ENABLED` | Enable GitHub SSO |
| `SSO_OIDC_OKTA_ENABLED` | Enable Okta SSO |
| `SSO_CALLBACK_URL` | External base URL for OAuth callbacks (required behind reverse proxy) |
For the full SSO configuration reference and setup guides, see [SSO Authentication →](/features/sso).
## Required volume mounts
### Docker socket
+153
View File
@@ -0,0 +1,153 @@
---
title: SSO Setup Guide
description: Step-by-step instructions for connecting Sencho to your identity provider.
---
<Note>
SSO requires a Sencho **Team Pro** license. You can configure SSO via environment variables (shown below) or from the Settings UI after first boot.
</Note>
## Google OIDC
1. Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Create a new **OAuth 2.0 Client ID** (Application type: Web application)
3. Add an **Authorized redirect URI**: `https://sencho.example.com/api/auth/sso/oidc/oidc_google/callback`
4. Copy the **Client ID** and **Client Secret**
5. Add to your `docker-compose.yml`:
```yaml
services:
sencho:
image: saelix/sencho:latest
environment:
- COMPOSE_DIR=/opt/compose
- SSO_OIDC_GOOGLE_ENABLED=true
- SSO_OIDC_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
- SSO_OIDC_GOOGLE_CLIENT_SECRET=your-client-secret
- SSO_CALLBACK_URL=https://sencho.example.com
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./sencho-data:/app/data
- /opt/compose:/opt/compose
```
Restart Sencho. A "Google" button will appear on the login page.
## GitHub OAuth
1. Go to **GitHub → Settings → Developer Settings → [OAuth Apps](https://github.com/settings/developers)**
2. Click **New OAuth App**
3. Set:
- **Application name**: Sencho
- **Homepage URL**: `https://sencho.example.com`
- **Authorization callback URL**: `https://sencho.example.com/api/auth/sso/oidc/oidc_github/callback`
4. Copy the **Client ID** and generate a **Client Secret**
5. Add to your `docker-compose.yml`:
```yaml
environment:
- SSO_OIDC_GITHUB_ENABLED=true
- SSO_OIDC_GITHUB_CLIENT_ID=your-github-client-id
- SSO_OIDC_GITHUB_CLIENT_SECRET=your-github-client-secret
- SSO_CALLBACK_URL=https://sencho.example.com
```
## Okta OIDC
1. In the [Okta Admin Console](https://admin.okta.com), go to **Applications → Create App Integration**
2. Select **OIDC - OpenID Connect** and **Web Application**
3. Set the **Sign-in redirect URI** to: `https://sencho.example.com/api/auth/sso/oidc/oidc_okta/callback`
4. Note your **Okta domain** (e.g., `https://dev-123456.okta.com`)
5. Copy the **Client ID** and **Client Secret**
6. Add to your `docker-compose.yml`:
```yaml
environment:
- SSO_OIDC_OKTA_ENABLED=true
- SSO_OIDC_OKTA_ISSUER_URL=https://dev-123456.okta.com
- SSO_OIDC_OKTA_CLIENT_ID=your-okta-client-id
- SSO_OIDC_OKTA_CLIENT_SECRET=your-okta-client-secret
- SSO_CALLBACK_URL=https://sencho.example.com
```
## LDAP / Active Directory
1. Identify your LDAP server's URL and port (default: `389` for LDAP, `636` for LDAPS)
2. Create a **read-only service account** (bind DN) that can search the user directory
3. Determine the **search base** (where users live in the directory tree)
4. Choose the right **search filter**:
- OpenLDAP: `(uid={{username}})`
- Active Directory: `(sAMAccountName={{username}})`
5. Optionally identify an **admin group DN** for role mapping
6. Add to your `docker-compose.yml`:
```yaml
environment:
- SSO_LDAP_ENABLED=true
- SSO_LDAP_URL=ldap://ldap.example.com:389
- SSO_LDAP_BIND_DN=cn=readonly,dc=example,dc=com
- SSO_LDAP_BIND_PASSWORD=your-bind-password
- SSO_LDAP_SEARCH_BASE=ou=users,dc=example,dc=com
- SSO_LDAP_SEARCH_FILTER=(uid={{username}})
- SSO_LDAP_ADMIN_GROUP_DN=cn=sencho-admins,ou=groups,dc=example,dc=com
- SSO_LDAP_DEFAULT_ROLE=viewer
```
After starting Sencho, verify the connection in **Settings → SSO → Test Connection**.
<Warning>
If your LDAP server is on the Docker host (not in a container), use the host's LAN IP or `host.docker.internal` (Docker Desktop) instead of `localhost`.
</Warning>
## Role mapping
By default, all SSO users are assigned the **Viewer** role. To grant Admin to specific users:
**For LDAP**: Set `SSO_LDAP_ADMIN_GROUP_DN` to the DN of your admin group. Users who are members of that group will be provisioned as Admin.
**For OIDC**: Set these two variables:
```yaml
environment:
- SSO_OIDC_ADMIN_CLAIM=groups
- SSO_OIDC_ADMIN_CLAIM_VALUE=sencho-admins
```
This tells Sencho to check the `groups` claim in the OIDC ID token. If it contains `sencho-admins`, the user gets Admin.
## Full docker-compose.yml example with SSO
```yaml
services:
sencho:
image: saelix/sencho:latest
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./sencho-data:/app/data
- /opt/compose:/opt/compose
environment:
- COMPOSE_DIR=/opt/compose
- DATA_DIR=/app/data
# Google SSO
- SSO_OIDC_GOOGLE_ENABLED=true
- SSO_OIDC_GOOGLE_CLIENT_ID=your-google-client-id
- SSO_OIDC_GOOGLE_CLIENT_SECRET=your-google-secret
# LDAP
- SSO_LDAP_ENABLED=true
- SSO_LDAP_URL=ldap://ldap.example.com:389
- SSO_LDAP_BIND_DN=cn=readonly,dc=example,dc=com
- SSO_LDAP_BIND_PASSWORD=your-bind-password
- SSO_LDAP_SEARCH_BASE=ou=users,dc=example,dc=com
- SSO_LDAP_SEARCH_FILTER=(sAMAccountName={{username}})
- SSO_LDAP_ADMIN_GROUP_DN=cn=sencho-admins,ou=groups,dc=example,dc=com
# Role mapping & callback
- SSO_OIDC_ADMIN_CLAIM=groups
- SSO_OIDC_ADMIN_CLAIM_VALUE=sencho-admins
- SSO_DEFAULT_ROLE=viewer
- SSO_CALLBACK_URL=https://sencho.example.com
```
For the complete list of environment variables and their defaults, see [SSO & LDAP Authentication →](/features/sso#sso-environment-variables-reference).
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+103 -5
View File
@@ -1,26 +1,82 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface SSOProvider {
provider: string;
displayName: string;
type: 'ldap' | 'oidc';
}
function getProviderIcon(provider: string) {
switch (provider) {
case 'oidc_google':
return (
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
);
case 'oidc_github':
return (
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
case 'oidc_okta':
return (
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.389 0 0 5.389 0 12s5.389 12 12 12 12-5.389 12-12S18.611 0 12 0zm0 18c-3.314 0-6-2.686-6-6s2.686-6 6-6 6 2.686 6 6-2.686 6-6 6z" />
</svg>
);
default:
return null;
}
}
export function Login({
className,
...props
}: React.ComponentPropsWithoutRef<"div">) {
const { login } = useAuth();
const { login, ssoLdapLogin } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [error, setError] = useState(() => {
const params = new URLSearchParams(window.location.search);
const ssoError = params.get('sso_error');
if (ssoError) {
window.history.replaceState({}, '', window.location.pathname);
return ssoError;
}
return '';
});
const [isLoading, setIsLoading] = useState(false);
const [loginMode, setLoginMode] = useState<'local' | 'ldap'>('local');
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
useEffect(() => {
fetch('/api/auth/sso/providers', { credentials: 'include' })
.then(r => r.ok ? r.json() : [])
.then((providers: SSOProvider[]) => setSsoProviders(providers))
.catch(() => {});
}, []);
const hasLdap = ssoProviders.some(p => p.type === 'ldap');
const oidcProviders = ssoProviders.filter(p => p.type === 'oidc');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
const result = await login(username, password);
const result = loginMode === 'ldap' && ssoLdapLogin
? await ssoLdapLogin(username, password)
: await login(username, password);
if (!result.success) {
setError(result.error || 'Login failed');
@@ -106,10 +162,52 @@ export function Login({
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
{isLoading
? 'Logging in...'
: loginMode === 'ldap'
? 'Sign in with LDAP'
: 'Login'
}
</Button>
{hasLdap && (
<button
type="button"
className="text-sm text-muted-foreground hover:text-foreground text-center transition-colors"
onClick={() => setLoginMode(loginMode === 'local' ? 'ldap' : 'local')}
>
{loginMode === 'local' ? 'Sign in with LDAP instead' : 'Sign in with password instead'}
</button>
)}
</div>
</form>
{oidcProviders.length > 0 && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<div className="flex flex-col gap-2">
{oidcProviders.map(p => (
<Button
key={p.provider}
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `/api/auth/sso/oidc/${p.provider}/authorize`;
}}
>
{getProviderIcon(p.provider)}
{p.displayName}
</Button>
))}
</div>
</>
)}
</div>
</div>
</div>
+365
View File
@@ -0,0 +1,365 @@
import { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { ProGate } from './ProGate';
import { Shield, Loader2, CheckCircle, XCircle } from 'lucide-react';
interface SSOProviderConfig {
provider: string;
enabled: boolean;
displayName: string;
// LDAP
ldapUrl?: string;
ldapBindDn?: string;
ldapBindPassword?: string;
ldapSearchBase?: string;
ldapSearchFilter?: string;
ldapAdminGroupDn?: string;
ldapDefaultRole?: string;
ldapTlsRejectUnauthorized?: boolean;
// OIDC
oidcIssuerUrl?: string;
oidcClientId?: string;
oidcClientSecret?: string;
oidcScopes?: string;
oidcAdminClaim?: string;
oidcAdminClaimValue?: string;
oidcDefaultRole?: string;
}
const PROVIDERS = [
{ id: 'ldap', label: 'LDAP / Active Directory', type: 'ldap' as const },
{ id: 'oidc_google', label: 'Google', type: 'oidc' as const },
{ id: 'oidc_github', label: 'GitHub', type: 'oidc' as const },
{ id: 'oidc_okta', label: 'Okta', type: 'oidc' as const },
];
function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
providerId: string;
type: 'ldap' | 'oidc';
label: string;
initialConfig: SSOProviderConfig | null;
onSave: () => void;
}) {
const [config, setConfig] = useState<Partial<SSOProviderConfig>>(initialConfig || { enabled: false });
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null);
const [expanded, setExpanded] = useState(!!initialConfig?.enabled);
const update = (field: string, value: string | boolean) => {
setConfig(prev => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
setSaving(true);
try {
const body = {
...config,
provider: providerId,
displayName: config.displayName || label,
};
const res = await apiFetch(`/sso/config/${providerId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
toast.success('SSO configuration saved');
onSave();
} else {
const data = await res.json();
toast.error(data?.error || data?.message || 'Failed to save');
}
} catch (error: unknown) {
toast.error((error as Error)?.message || 'Failed to save SSO configuration');
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await apiFetch(`/sso/config/${providerId}/test`, { method: 'POST' });
const data = await res.json();
setTestResult(data);
if (data.success) {
toast.success('Connection successful');
} else {
toast.error(data.error || 'Connection failed');
}
} catch {
setTestResult({ success: false, error: 'Connection test failed' });
} finally {
setTesting(false);
}
};
const handleDelete = async () => {
try {
const res = await apiFetch(`/sso/config/${providerId}`, { method: 'DELETE' });
if (res.ok) {
toast.success('SSO provider removed');
setConfig({ enabled: false });
setExpanded(false);
onSave();
}
} catch {
toast.error('Failed to remove provider');
}
};
return (
<div className="border border-border rounded-lg">
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-3">
<span className="font-medium text-sm">{label}</span>
{initialConfig?.enabled && (
<Badge variant="secondary" className="text-xs bg-green-500/10 text-green-500 border-green-500/20">
Active
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={!!config.enabled}
onCheckedChange={(checked) => update('enabled', checked)}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
{expanded && (
<div className="border-t border-border p-4 space-y-4">
{type === 'ldap' ? (
<>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Server URL</Label>
<Input
placeholder="ldap://ldap.example.com:389"
value={config.ldapUrl || ''}
onChange={e => update('ldapUrl', e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Bind DN</Label>
<Input
placeholder="cn=readonly,dc=example,dc=com"
value={config.ldapBindDn || ''}
onChange={e => update('ldapBindDn', e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Bind Password</Label>
<Input
type="password"
placeholder="Enter to update"
value={config.ldapBindPassword || ''}
onChange={e => update('ldapBindPassword', e.target.value)}
/>
</div>
</div>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Search Base</Label>
<Input
placeholder="ou=users,dc=example,dc=com"
value={config.ldapSearchBase || ''}
onChange={e => update('ldapSearchBase', e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Search Filter</Label>
<Input
placeholder="(uid={{username}})"
value={config.ldapSearchFilter || ''}
onChange={e => update('ldapSearchFilter', e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Use <code className="bg-muted px-1 rounded">{'{{username}}'}</code> as placeholder.
For Active Directory: <code className="bg-muted px-1 rounded">{'(sAMAccountName={{username}})'}</code>
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Admin Group DN</Label>
<Input
placeholder="cn=sencho-admins,ou=groups,dc=..."
value={config.ldapAdminGroupDn || ''}
onChange={e => update('ldapAdminGroupDn', e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Default Role</Label>
<Select
value={config.ldapDefaultRole || 'viewer'}
onValueChange={v => update('ldapDefaultRole', v)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.ldapTlsRejectUnauthorized !== false}
onCheckedChange={checked => update('ldapTlsRejectUnauthorized', checked)}
/>
<Label className="text-xs text-muted-foreground">Verify TLS certificate</Label>
</div>
</>
) : (
<>
{providerId === 'oidc_okta' && (
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Issuer URL</Label>
<Input
placeholder="https://dev-123456.okta.com"
value={config.oidcIssuerUrl || ''}
onChange={e => update('oidcIssuerUrl', e.target.value)}
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Client ID</Label>
<Input
placeholder="Client ID"
value={config.oidcClientId || ''}
onChange={e => update('oidcClientId', e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Client Secret</Label>
<Input
type="password"
placeholder="Enter to update"
value={config.oidcClientSecret || ''}
onChange={e => update('oidcClientSecret', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Admin Claim</Label>
<Input
placeholder="groups"
value={config.oidcAdminClaim || ''}
onChange={e => update('oidcAdminClaim', e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Admin Claim Value</Label>
<Input
placeholder="sencho-admins"
value={config.oidcAdminClaimValue || ''}
onChange={e => update('oidcAdminClaimValue', e.target.value)}
/>
</div>
</div>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Default Role</Label>
<Select
value={config.oidcDefaultRole || 'viewer'}
onValueChange={v => update('oidcDefaultRole', v)}
>
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><Loader2 className="w-3 h-3 mr-1 animate-spin" /> Saving...</> : 'Save'}
</Button>
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing}>
{testing ? <><Loader2 className="w-3 h-3 mr-1 animate-spin" /> Testing...</> : 'Test Connection'}
</Button>
{testResult && (
testResult.success
? <CheckCircle className="w-4 h-4 text-green-500" />
: <XCircle className="w-4 h-4 text-red-500" />
)}
</div>
{initialConfig && (
<Button size="sm" variant="ghost" className="text-red-500 hover:text-red-400" onClick={handleDelete}>
Remove
</Button>
)}
</div>
</div>
)}
</div>
);
}
export function SSOSection() {
const [configs, setConfigs] = useState<SSOProviderConfig[]>([]);
const fetchConfigs = async () => {
try {
const res = await apiFetch('/sso/config');
if (res.ok) setConfigs(await res.json());
} catch { /* ignore - ProGate will handle non-pro */ }
};
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchConfigs(); }, []);
const getConfig = (provider: string) => configs.find(c => c.provider === provider) || null;
return (
<ProGate featureName="SSO Authentication">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<Shield className="w-5 h-5" />
SSO Authentication
</h3>
<p className="text-sm text-muted-foreground mt-1">
Connect your identity provider so team members can sign in with their existing credentials.
SSO works alongside password authentication it does not replace it.
</p>
</div>
<div className="space-y-3">
{PROVIDERS.map(p => (
<ProviderCard
key={p.id}
providerId={p.id}
type={p.type}
label={p.label}
initialConfig={getConfig(p.id)}
onSave={fetchConfigs}
/>
))}
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>SSO users are automatically provisioned on first login and assigned a role based on your identity provider's group membership.</p>
<p>For OIDC providers, set the OAuth callback URL to: <code className="bg-muted px-1 rounded">{'https://<your-sencho-url>/api/auth/sso/oidc/<provider>/callback'}</code></p>
</div>
</div>
</ProGate>
);
}
+10 -2
View File
@@ -27,6 +27,7 @@ import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from './TierBadge';
import { ProGate } from './ProGate';
import { SSOSection } from './SSOSection';
interface Agent {
type: 'discord' | 'slack' | 'webhook';
@@ -48,7 +49,7 @@ interface PatchableSettings {
log_retention_days?: string;
}
type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about';
type SectionId = 'account' | 'license' | 'users' | 'sso' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about';
interface WebhookItem {
id: number;
@@ -656,7 +657,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
// When switching to a remote node, reset to a node-scoped section if on a global-only one
useEffect(() => {
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
setActiveSection('system');
}
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -997,6 +998,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{!isRemote && isAdmin && (
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" />
)}
{!isRemote && isAdmin && (
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" />
)}
<NavButton
section="system"
icon={<Activity className="w-4 h-4 mr-2" />}
@@ -1452,6 +1456,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<UsersSection />
)}
{activeSection === 'sso' && (
<SSOSection />
)}
{activeSection === 'developer' && (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
+25
View File
@@ -14,6 +14,7 @@ interface AuthContextType {
user: UserInfo | null;
isAdmin: boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
logout: () => Promise<void>;
completeSetup: () => void;
checkAuth: () => Promise<void>;
@@ -91,6 +92,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
const ssoLdapLogin = async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
try {
const response = await fetch('/api/auth/sso/ldap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok && data.success) {
setAppStatus('authenticated');
await checkAuth();
return { success: true };
} else {
return { success: false, error: data.error || 'LDAP login failed' };
}
} catch {
return { success: false, error: 'Network error. Please try again.' };
}
};
const logout = async () => {
try {
await fetch('/api/auth/logout', {
@@ -118,6 +142,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
user,
isAdmin: user?.role === 'admin',
login,
ssoLdapLogin,
logout,
completeSetup,
checkAuth