mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
1d89e8ce59
Rate-limit OIDC callback route and clear state cookie on all paths. Validate GitHub API responses and add server-side config validation. Sync SSO user roles on every login respecting seat limits. Add LDAP connection timeout, bind DN warning, and multiple entry logging. Replace Select with Combobox and apply card design tokens in SSOSection. Expose OIDC scopes field in settings UI. Fix hardcoded colors to use design system tokens. Add standard and diagnostic logging throughout SSO flow. Add tests for role sync, seat limits, LDAP escaping, and config validation. Remove unused login-form.tsx template. Update docs and screenshots for SSO feature.
668 lines
27 KiB
TypeScript
668 lines
27 KiB
TypeScript
import crypto from 'crypto';
|
|
import { Client as LdapClient } from 'ldapts';
|
|
import {
|
|
Configuration,
|
|
discovery,
|
|
buildAuthorizationUrl,
|
|
authorizationCodeGrant,
|
|
fetchUserInfo,
|
|
randomState,
|
|
randomPKCECodeVerifier,
|
|
calculatePKCECodeChallenge,
|
|
} from 'openid-client';
|
|
import { DatabaseService, User, AuthProvider } from './DatabaseService';
|
|
import { CryptoService } from './CryptoService';
|
|
import { LicenseService } from './LicenseService';
|
|
import { CacheService } from './CacheService';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
|
|
// OIDC discovery metadata changes rarely; caching it eliminates the redundant
|
|
// HTTPS round-trip between getOIDCAuthorizationUrl and handleOIDCCallback in
|
|
// the same login flow, and across back-to-back logins.
|
|
const OIDC_CONFIG_TTL_MS = 5 * 60 * 1000;
|
|
const OIDC_CONFIG_NS = 'oidc-config';
|
|
|
|
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));
|
|
this.invalidateOIDCConfigCache(provider);
|
|
}
|
|
|
|
private invalidateOIDCConfigCache(provider: string): void {
|
|
CacheService.getInstance().invalidate(`${OIDC_CONFIG_NS}:${provider}`);
|
|
}
|
|
|
|
// --- 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)
|
|
);
|
|
// Client ID, secret, or issuer URL may have changed, so drop the cached Configuration.
|
|
this.invalidateOIDCConfigCache(config.provider);
|
|
}
|
|
|
|
public deleteProviderConfig(provider: string): void {
|
|
DatabaseService.getInstance().deleteSSOConfig(provider);
|
|
this.invalidateOIDCConfigCache(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 debug = isDebugEnabled();
|
|
if (debug) console.debug('[SSO:debug] LDAP auth attempt', { username, hasBindDn: !!config.ldapBindDn, url: config.ldapUrl });
|
|
|
|
const client = new LdapClient({
|
|
url: config.ldapUrl,
|
|
connectTimeout: 10000,
|
|
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);
|
|
} else if (config.ldapBindDn && !config.ldapBindPassword) {
|
|
console.warn('[SSO] LDAP bind DN configured but bind password is empty; anonymous bind will be used');
|
|
}
|
|
|
|
// 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' };
|
|
}
|
|
|
|
if (searchEntries.length > 1) {
|
|
console.warn(`[SSO] LDAP search returned ${searchEntries.length} entries for username "${username}"; using first match. Consider narrowing your search filter.`);
|
|
}
|
|
|
|
const userEntry = searchEntries[0];
|
|
const userDn = userEntry.dn;
|
|
if (debug) console.debug('[SSO:debug] LDAP user found', { dn: userDn, hasEmail: !!(userEntry['mail'] || userEntry['email']) });
|
|
|
|
// Step 3: Bind as the user to verify their password
|
|
await client.unbind();
|
|
const userClient = new LdapClient({
|
|
url: config.ldapUrl,
|
|
connectTimeout: 10000,
|
|
tlsOptions: {
|
|
rejectUnauthorized: config.ldapTlsRejectUnauthorized !== false,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await userClient.bind(userDn, password);
|
|
} catch {
|
|
return { success: false, error: 'Invalid credentials' };
|
|
} finally {
|
|
try { await userClient.unbind(); } catch (e) { if (debug) console.debug('[SSO:debug] LDAP user unbind error (non-critical):', (e as Error).message); }
|
|
}
|
|
|
|
// Step 4: Determine role from group membership
|
|
const role = this.resolveRoleFromLdap(userEntry, config);
|
|
if (debug) console.debug('[SSO:debug] LDAP role resolved', { username, role });
|
|
|
|
// 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 (e) { if (debug) console.debug('[SSO:debug] LDAP service unbind error (non-critical):', (e as Error).message); }
|
|
}
|
|
}
|
|
|
|
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 oidcConfig = await this.getOIDCConfig(provider, config);
|
|
const state = randomState();
|
|
const codeVerifier = randomPKCECodeVerifier();
|
|
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
|
|
|
const scopes = config.oidcScopes || 'openid email profile';
|
|
if (isDebugEnabled()) console.debug('[SSO:debug] OIDC auth URL', { provider, scopes });
|
|
|
|
const url = buildAuthorizationUrl(oidcConfig, {
|
|
redirect_uri: callbackUrl,
|
|
scope: scopes,
|
|
state,
|
|
code_challenge: codeChallenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
|
|
return { url: url.href, state, codeVerifier };
|
|
}
|
|
|
|
public async handleOIDCCallback(
|
|
provider: string,
|
|
callbackUrl: string,
|
|
params: { code: string; state: string },
|
|
expectedState: string,
|
|
codeVerifier: string
|
|
): Promise<SSOAuthResult> {
|
|
const config = this.getProviderConfigDecrypted(provider);
|
|
if (!config || !config.enabled) {
|
|
return { success: false, error: `SSO provider ${provider} is not configured` };
|
|
}
|
|
|
|
try {
|
|
const oidcConfig = await this.getOIDCConfig(provider, config);
|
|
|
|
const currentUrl = new URL(callbackUrl);
|
|
currentUrl.searchParams.set('code', params.code);
|
|
currentUrl.searchParams.set('state', params.state);
|
|
|
|
const tokens = await authorizationCodeGrant(oidcConfig, currentUrl, {
|
|
pkceCodeVerifier: codeVerifier,
|
|
expectedState,
|
|
});
|
|
|
|
let userInfo: Record<string, unknown>;
|
|
|
|
if (provider === 'oidc_github') {
|
|
// GitHub doesn't support standard OIDC userinfo; use their API
|
|
userInfo = await this.fetchGitHubUserInfo(tokens.access_token);
|
|
} else {
|
|
const claims = tokens.claims();
|
|
if (!claims) {
|
|
return { success: false, error: 'OIDC provider did not return an ID token' };
|
|
}
|
|
// Pass claims.sub so v6 rejects userinfo/id_token subject mismatches.
|
|
try {
|
|
const info = await fetchUserInfo(oidcConfig, tokens.access_token, String(claims.sub));
|
|
userInfo = { ...claims, ...info };
|
|
} catch (err) {
|
|
// Log so a subject-mismatch rejection is not silently hidden.
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.warn(`[SSO] fetchUserInfo failed, falling back to id_token claims: ${message}`);
|
|
userInfo = { ...claims };
|
|
}
|
|
}
|
|
|
|
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_${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 });
|
|
|
|
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' },
|
|
}),
|
|
]);
|
|
|
|
if (!userRes.ok) {
|
|
throw new Error(`GitHub user API returned ${userRes.status}`);
|
|
}
|
|
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 getOIDCConfig(
|
|
provider: string,
|
|
config: SSOProviderConfig,
|
|
): Promise<Configuration> {
|
|
const clientId = config.oidcClientId || '';
|
|
const clientSecret = config.oidcClientSecret || undefined;
|
|
|
|
if (provider === 'oidc_github') {
|
|
// GitHub is not a standard OIDC provider. Construct Configuration
|
|
// directly from known endpoints instead of going through discovery.
|
|
return new Configuration(
|
|
{
|
|
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',
|
|
},
|
|
clientId,
|
|
clientSecret,
|
|
);
|
|
}
|
|
|
|
const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider];
|
|
if (!issuerUrl) {
|
|
throw new Error(`Issuer URL not configured for ${provider}`);
|
|
}
|
|
return CacheService.getInstance().getOrFetch(
|
|
`${OIDC_CONFIG_NS}:${provider}`,
|
|
OIDC_CONFIG_TTL_MS,
|
|
() => discovery(new URL(issuerUrl), clientId, clientSecret),
|
|
);
|
|
}
|
|
|
|
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();
|
|
const debug = isDebugEnabled();
|
|
if (debug) console.debug('[SSO:debug] provisionUser', { authProvider: params.authProvider, providerId: params.providerId, preferredUsername: params.preferredUsername, role: params.role });
|
|
|
|
// Check if user already exists by provider identity
|
|
const existing = db.getUserByProviderIdentity(params.authProvider, params.providerId);
|
|
if (existing) {
|
|
const updates: Partial<{ email: string; role: string }> = {};
|
|
|
|
// Update email if changed
|
|
if (params.email && params.email !== existing.email) {
|
|
updates.email = params.email;
|
|
}
|
|
|
|
// Sync role from identity provider on every login
|
|
if (params.role !== existing.role) {
|
|
if (params.role === 'admin') {
|
|
const seatLimits = LicenseService.getInstance().getSeatLimits();
|
|
if (seatLimits.maxAdmins === null || db.getAdminCount() < seatLimits.maxAdmins) {
|
|
updates.role = params.role;
|
|
}
|
|
// If admin seats full, keep current role rather than upgrading
|
|
} else {
|
|
// Always allow demotion (e.g., removed from admin group)
|
|
updates.role = params.role;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
if (debug) console.debug('[SSO:debug] Updating existing user', { userId: existing.id, username: existing.username, updates });
|
|
db.updateUser(existing.id, updates);
|
|
}
|
|
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) {
|
|
console.warn(`[SSO] Admin seat limit reached; provisioning ${params.preferredUsername} as viewer instead of admin`);
|
|
role = 'viewer';
|
|
}
|
|
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');
|
|
if (debug) console.debug('[SSO:debug] New user provisioned', { userId: user.id, username, role });
|
|
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 debug = isDebugEnabled();
|
|
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 (e) { if (debug) console.debug('[SSO:debug] LDAP test unbind error (non-critical):', (e as Error).message); }
|
|
}
|
|
}
|
|
|
|
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` };
|
|
}
|
|
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' };
|
|
}
|
|
if (!config.oidcClientId) {
|
|
return { success: false, error: 'Client ID is required to test discovery' };
|
|
}
|
|
|
|
try {
|
|
const oidcConfig = await discovery(
|
|
new URL(issuerUrl),
|
|
config.oidcClientId,
|
|
config.oidcClientSecret || undefined,
|
|
);
|
|
return { success: true, issuer: oidcConfig.serverMetadata().issuer };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Discovery failed';
|
|
return { success: false, error: message };
|
|
}
|
|
}
|
|
}
|