mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
feat: add Custom OIDC provider and move SSO to Community tier (#626)
* feat: add Custom OIDC provider and move SSO to Community tier Add a generic Custom OIDC provider that works with any spec-compliant OIDC identity provider (Keycloak, Authentik, Authelia, Zitadel, KanIDM, Pocket ID, etc.) via standard discovery. Supports configurable claim mapping for User ID, Username, and Email fields to handle non-standard providers. Move all SSO functionality (LDAP and OIDC) from the Admiral tier to the Community tier so every user has access to identity provider integration. Backend: add oidc_custom to AuthProvider type, extend SSOService with claim mapping fields and env-var seeding, add oidc_custom to route validation, remove requireAdmiral guards from SSO config endpoints. Frontend: add Custom OIDC card with Display Name, Issuer URL, and claim mapping fields to SSOSection; add KeyRound icon on login page; remove AdmiralGate wrapper and lock icon from SSO settings nav. Tests: update tier guard expectations, add oidc_custom authorize/config/ provisioning tests and claim mapping coverage. All 992 tests pass. Docs: add Custom OIDC configuration reference, provider-specific setup examples, troubleshooting section, and updated screenshots. * fix: settings dialog close button overlap and combobox styling Reposition the close button in Settings Hub above the scroll area so it stays fixed when content scrolls. Increase dialog height to accommodate the growing number of setting sections. Fix combobox trigger styling to match Input component tokens (border-glass-border, bg-input) and eliminate the gap between trigger and dropdown list (top-full -mt-px). Apply the same fixes to multi-select-combobox for consistency. Add items-start to the Scopes/Default Role grid so the combobox aligns with the adjacent input field. Add showClose prop to DialogContent for consumers that need custom close button placement. Update SSO doc screenshots at 1920x900.
This commit is contained in:
@@ -50,13 +50,13 @@ describe('SSO Config Endpoints (Protected)', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /api/sso/config returns 403 without Admiral', async () => {
|
||||
it('GET /api/sso/config returns 200 with admin token (no Admiral required)', async () => {
|
||||
const res = await supertest(app)
|
||||
.get('/api/sso/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
// Without an Admiral license, this should be 403
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
// SSO config is now available to all tiers, only admin role required
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('PUT /api/sso/config/:provider returns 401 without auth', async () => {
|
||||
@@ -85,6 +85,12 @@ describe('SSO OIDC Authorize', () => {
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('sso_error');
|
||||
});
|
||||
|
||||
it('GET /api/auth/sso/oidc/oidc_custom/authorize redirects to error when not configured', async () => {
|
||||
const res = await supertest(app).get('/api/auth/sso/oidc/oidc_custom/authorize');
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('sso_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSO OIDC Callback', () => {
|
||||
@@ -192,6 +198,22 @@ describe('SSO User Provisioning', () => {
|
||||
expect(user.auth_provider).toBe('ldap');
|
||||
});
|
||||
|
||||
it('provisionUser works with oidc_custom provider', async () => {
|
||||
const { SSOService } = await import('../services/SSOService');
|
||||
const sso = SSOService.getInstance();
|
||||
const user = sso.provisionUser({
|
||||
authProvider: 'oidc_custom',
|
||||
providerId: 'custom-sub-789',
|
||||
preferredUsername: 'customuser',
|
||||
email: 'custom@example.com',
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(user.auth_provider).toBe('oidc_custom');
|
||||
expect(user.provider_id).toBe('custom-sub-789');
|
||||
expect(user.email).toBe('custom@example.com');
|
||||
expect(user.username).toBe('customuser');
|
||||
});
|
||||
|
||||
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
|
||||
@@ -387,25 +409,12 @@ describe('LDAP Filter Escaping', () => {
|
||||
});
|
||||
|
||||
describe('SSO Config Validation on PUT', () => {
|
||||
// We need an Admiral-licensed admin token for these tests.
|
||||
// Mock getTier/getVariant so requireAdmiral passes.
|
||||
let admiralToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
admiralToken = jwt.sign({ username: 'testadmin', role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
// SSO config routes require admin role but no longer require Admiral tier
|
||||
|
||||
it('rejects enabled LDAP config without Server URL', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/ldap')
|
||||
.set('Authorization', `Bearer ${admiralToken}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: true, ldapSearchBase: 'ou=users,dc=example' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('Server URL');
|
||||
@@ -414,7 +423,7 @@ describe('SSO Config Validation on PUT', () => {
|
||||
it('rejects enabled LDAP config without Search Base', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/ldap')
|
||||
.set('Authorization', `Bearer ${admiralToken}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: true, ldapUrl: 'ldap://localhost:389' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('Search Base');
|
||||
@@ -423,7 +432,7 @@ describe('SSO Config Validation on PUT', () => {
|
||||
it('rejects enabled OIDC config without Client ID', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/oidc_google')
|
||||
.set('Authorization', `Bearer ${admiralToken}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('Client ID');
|
||||
@@ -432,16 +441,34 @@ describe('SSO Config Validation on PUT', () => {
|
||||
it('rejects enabled Okta config without Issuer URL', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/oidc_okta')
|
||||
.set('Authorization', `Bearer ${admiralToken}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: true, oidcClientId: 'test-client-id' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('Issuer URL');
|
||||
});
|
||||
|
||||
it('rejects enabled Custom OIDC config without Issuer URL', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/oidc_custom')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: true, oidcClientId: 'test-client-id' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('Issuer URL');
|
||||
});
|
||||
|
||||
it('accepts oidc_custom as a valid provider', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/oidc_custom')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows saving disabled config without required fields', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/ldap')
|
||||
.set('Authorization', `Bearer ${admiralToken}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -450,9 +477,32 @@ describe('SSO Config Validation on PUT', () => {
|
||||
it('rejects invalid provider name', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/invalid_provider')
|
||||
.set('Authorization', `Bearer ${admiralToken}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('Invalid SSO provider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSO Claim Mapping', () => {
|
||||
it('resolveRoleFromOidc respects custom admin claim name', async () => {
|
||||
const { SSOService } = await import('../services/SSOService');
|
||||
const sso = SSOService.getInstance();
|
||||
// Access private method for testing
|
||||
const resolve = (sso as unknown as {
|
||||
resolveRoleFromOidc: (userInfo: Record<string, unknown>, config: { oidcAdminClaim?: string; oidcAdminClaimValue?: string; oidcDefaultRole?: string }) => string;
|
||||
}).resolveRoleFromOidc.bind(sso);
|
||||
|
||||
// Standard claim name
|
||||
expect(resolve({ groups: ['sencho-admins'] }, { oidcAdminClaim: 'groups', oidcAdminClaimValue: 'sencho-admins' })).toBe('admin');
|
||||
|
||||
// Custom claim name
|
||||
expect(resolve({ roles: 'admin-role' }, { oidcAdminClaim: 'roles', oidcAdminClaimValue: 'admin-role' })).toBe('admin');
|
||||
|
||||
// Claim missing, falls back to default
|
||||
expect(resolve({}, { oidcAdminClaim: 'roles', oidcAdminClaimValue: 'admin-role', oidcDefaultRole: 'viewer' })).toBe('viewer');
|
||||
|
||||
// Claim present but no match
|
||||
expect(resolve({ roles: 'user-role' }, { oidcAdminClaim: 'roles', oidcAdminClaimValue: 'admin-role', oidcDefaultRole: 'viewer' })).toBe('viewer');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -923,7 +923,7 @@ app.post('/api/auth/sso/ldap', authRateLimiter, async (req: Request, res: Respon
|
||||
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'];
|
||||
const validProviders = ['oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'];
|
||||
if (!validProviders.includes(provider)) {
|
||||
res.status(400).json({ error: 'Invalid SSO provider' });
|
||||
return;
|
||||
@@ -5654,7 +5654,7 @@ app.post('/api/system/console-token', authMiddleware, (req: Request, res: Respon
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSO Config Routes (admin + Admiral, local-only) ---
|
||||
// --- SSO Config Routes (admin, local-only) ---
|
||||
|
||||
app.get('/api/sso/config', (req: Request, res: Response): void => {
|
||||
if (req.apiTokenScope) {
|
||||
@@ -5662,7 +5662,6 @@ app.get('/api/sso/config', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const configs = DatabaseService.getInstance().getSSOConfigs();
|
||||
const result = configs.map(c => {
|
||||
@@ -5685,7 +5684,6 @@ app.get('/api/sso/config/:provider', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const config = SSOService.getInstance().getProviderConfig(String(req.params.provider));
|
||||
if (!config) {
|
||||
@@ -5709,10 +5707,9 @@ app.put('/api/sso/config/:provider', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const provider = String(req.params.provider);
|
||||
const validProviders = ['ldap', 'oidc_google', 'oidc_github', 'oidc_okta'];
|
||||
const validProviders = ['ldap', 'oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'];
|
||||
if (!validProviders.includes(provider)) {
|
||||
res.status(400).json({ error: 'Invalid SSO provider' });
|
||||
return;
|
||||
@@ -5727,7 +5724,7 @@ app.put('/api/sso/config/:provider', (req: Request, res: Response): void => {
|
||||
if (!config.ldapSearchBase?.trim()) missing.push('Search Base');
|
||||
} else {
|
||||
if (!config.oidcClientId?.trim()) missing.push('Client ID');
|
||||
if (provider === 'oidc_okta' && !config.oidcIssuerUrl?.trim()) missing.push('Issuer URL');
|
||||
if ((provider === 'oidc_okta' || provider === 'oidc_custom') && !config.oidcIssuerUrl?.trim()) missing.push('Issuer URL');
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
res.status(400).json({ error: `Missing required fields: ${missing.join(', ')}` });
|
||||
@@ -5750,7 +5747,6 @@ app.delete('/api/sso/config/:provider', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const deletedProvider = String(req.params.provider);
|
||||
SSOService.getInstance().deleteProviderConfig(deletedProvider);
|
||||
@@ -5768,7 +5764,6 @@ app.post('/api/sso/config/:provider/test', async (req: Request, res: Response):
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const provider = String(req.params.provider);
|
||||
if (provider === 'ldap') {
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface WebhookExecution {
|
||||
executed_at: number;
|
||||
}
|
||||
|
||||
export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta';
|
||||
export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta' | 'oidc_custom';
|
||||
|
||||
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
|
||||
export type ResourceType = 'stack' | 'node';
|
||||
|
||||
@@ -43,6 +43,10 @@ export interface SSOProviderConfig {
|
||||
oidcAdminClaim?: string;
|
||||
oidcAdminClaimValue?: string;
|
||||
oidcDefaultRole?: 'admin' | 'viewer';
|
||||
// Custom OIDC claim mapping
|
||||
oidcIdClaim?: string;
|
||||
oidcUsernameClaim?: string;
|
||||
oidcEmailClaim?: string;
|
||||
}
|
||||
|
||||
export interface SSOAuthResult {
|
||||
@@ -61,6 +65,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
oidc_google: 'Google',
|
||||
oidc_github: 'GitHub',
|
||||
oidc_okta: 'Okta',
|
||||
oidc_custom: 'Custom OIDC',
|
||||
};
|
||||
|
||||
const WELL_KNOWN_ISSUERS: Record<string, string> = {
|
||||
@@ -85,6 +90,7 @@ export class SSOService {
|
||||
this.seedOidcFromEnv('oidc_google', 'SSO_OIDC_GOOGLE');
|
||||
this.seedOidcFromEnv('oidc_github', 'SSO_OIDC_GITHUB');
|
||||
this.seedOidcFromEnv('oidc_okta', 'SSO_OIDC_OKTA');
|
||||
this.seedOidcFromEnv('oidc_custom', 'SSO_OIDC_CUSTOM');
|
||||
}
|
||||
|
||||
private seedLdapFromEnv(): void {
|
||||
@@ -123,7 +129,7 @@ export class SSOService {
|
||||
const config: SSOProviderConfig = {
|
||||
provider,
|
||||
enabled: true,
|
||||
displayName: PROVIDER_DISPLAY_NAMES[provider] || provider,
|
||||
displayName: process.env[`${envPrefix}_DISPLAY_NAME`] || 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`] || '',
|
||||
@@ -131,6 +137,9 @@ export class SSOService {
|
||||
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',
|
||||
oidcIdClaim: process.env[`${envPrefix}_ID_CLAIM`] || undefined,
|
||||
oidcUsernameClaim: process.env[`${envPrefix}_USERNAME_CLAIM`] || undefined,
|
||||
oidcEmailClaim: process.env[`${envPrefix}_EMAIL_CLAIM`] || undefined,
|
||||
};
|
||||
|
||||
const configForStorage = { ...config };
|
||||
@@ -416,13 +425,20 @@ export class SSOService {
|
||||
}
|
||||
}
|
||||
|
||||
const sub = String(userInfo.sub || userInfo.id || '');
|
||||
// Use configurable claim names for custom providers, with standard OIDC fallbacks
|
||||
const idClaimName = config.oidcIdClaim || 'sub';
|
||||
const usernameClaimName = config.oidcUsernameClaim || 'preferred_username';
|
||||
const emailClaimName = config.oidcEmailClaim || 'email';
|
||||
|
||||
const sub = String(userInfo[idClaimName] ?? 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_${sub.substring(0, 8)}`);
|
||||
const email = String(userInfo[emailClaimName] ?? userInfo.email ?? '');
|
||||
const name = String(
|
||||
userInfo[usernameClaimName] ?? userInfo.name ?? userInfo.preferred_username ?? userInfo.login ?? email.split('@')[0] ?? `sso_${sub.substring(0, 8)}`
|
||||
);
|
||||
const role = this.resolveRoleFromOidc(userInfo, config);
|
||||
if (isDebugEnabled()) console.debug('[SSO:debug] OIDC userInfo resolved', { provider, sub, email: email || '(none)', name, role });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user