import { query, queryOne } from '../../core/db.js'; import { HttpError } from '../../core/http.js'; import { config as appConfig } from '../../core/config.js'; export type TenantSecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system'; export interface TenantSecretConfig { secretScope: TenantSecretScope; secretKey: string; secretValue: string | null; secretJson: Record; } export interface TenantAuthProviderConfig { tenantId: string; provider: string; status: 'active' | 'testing' | 'disabled'; configPublic: Record; secret: TenantSecretConfig | null; } interface TenantAuthProviderRow { provider: string; status: 'active' | 'testing' | 'disabled'; configPublic: Record | null; } interface TenantSecretRow { secretScope: TenantSecretScope; secretKey: string; secretValue: string | null; secretJson: Record | null; } function objectValue(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } function parseSecretRef(value: unknown): { scope: TenantSecretScope; key: string } | null { if (typeof value !== 'string' || !value.trim()) return null; const parts = value.trim().split(':'); if (parts.length !== 3 || parts[0] !== 'app_private.tenant_secrets') return null; const scope = parts[1] as TenantSecretScope; const key = parts[2]; if (!['payment', 'sms', 'oauth', 'storage', 'crm', 'ai', 'system'].includes(scope) || !key) return null; return { scope, key }; } function defaultSecretScope(provider: string): TenantSecretScope { const normalized = provider.toLowerCase().replace(/[_\s]/g, '-'); if (normalized.includes('sms') || normalized === 'aliyun' || normalized === 'tencent') return 'sms'; return 'oauth'; } async function loadTenantSecret( tenantId: string, configPublic: Record, provider: string, ): Promise { const secretRef = parseSecretRef(configPublic.secretRef); const scope = secretRef?.scope || defaultSecretScope(provider); const key = secretRef?.key || provider; const row = await queryOne( ` select secret_scope as "secretScope", secret_key as "secretKey", secret_value as "secretValue", secret_json as "secretJson" from app_private.tenant_secrets where tenant_id = $1 and secret_scope = $2 and secret_key = $3 limit 1 `, [tenantId, scope, key], ); if (!row) return null; return { secretScope: row.secretScope, secretKey: row.secretKey, secretValue: row.secretValue, secretJson: objectValue(row.secretJson), }; } export async function loadTenantAuthProviderConfig( tenantId: string, providers: string[], ): Promise { const uniqueProviders = [...new Set(providers.map(item => item.trim()).filter(Boolean))]; if (uniqueProviders.length === 0) return null; const rows = await query( ` select provider, status, config_public as "configPublic" from public.tenant_auth_providers where tenant_id = $1 and provider = any($2::text[]) and status in ('active', 'testing') `, [tenantId, uniqueProviders], ); const row = uniqueProviders.map(provider => rows.find(item => item.provider === provider)).find(Boolean); if (!row) return null; const configPublic = objectValue(row.configPublic); const secret = await loadTenantSecret(tenantId, configPublic, row.provider); return { tenantId, provider: row.provider, status: row.status, configPublic, secret, }; } export function requirePublicString(config: TenantAuthProviderConfig, keys: string[], code: string) { for (const key of keys) { const value = config.configPublic[key]; if (typeof value === 'string' && value.trim()) return value.trim(); } throw new HttpError(503, `${config.provider} public config is missing ${keys[0]}`, code); } export function optionalPublicString(config: TenantAuthProviderConfig, keys: string[]) { for (const key of keys) { const value = config.configPublic[key]; if (typeof value === 'string' && value.trim()) return value.trim(); } return ''; } function isLocalDevHost(hostname: string) { return ['127.0.0.1', 'localhost', '::1'].includes(hostname); } function isAllowedHost(hostname: string, allowedHosts: string[]) { return allowedHosts.some(host => hostname === host || hostname.endsWith(`.${host}`)); } export function providerEndpoint( authProvider: TenantAuthProviderConfig, fallback: string, allowedHosts: string[], code = 'PROVIDER_ENDPOINT_NOT_ALLOWED', ) { const raw = optionalPublicString(authProvider, ['endpoint']) || fallback; let endpoint: URL; try { endpoint = new URL(raw); } catch { throw new HttpError(503, `${authProvider.provider} endpoint is invalid`, code); } const localDev = !appConfig.isProduction && isLocalDevHost(endpoint.hostname); if (!localDev && endpoint.protocol !== 'https:') { throw new HttpError(503, `${authProvider.provider} endpoint must use HTTPS`, code); } if (!localDev && !isAllowedHost(endpoint.hostname, allowedHosts)) { throw new HttpError(503, `${authProvider.provider} endpoint host is not allowed`, code); } endpoint.username = ''; endpoint.password = ''; return endpoint.toString(); } export function optionalPublicObject(config: TenantAuthProviderConfig, key: string) { return objectValue(config.configPublic[key]); } export function optionalPublicArray(config: TenantAuthProviderConfig, key: string) { const value = config.configPublic[key]; return Array.isArray(value) ? value : []; } export function secretString(config: TenantAuthProviderConfig, keys: string[]) { const secret = config.secret; if (!secret) return ''; if (typeof secret.secretValue === 'string' && secret.secretValue.trim()) return secret.secretValue.trim(); for (const key of keys) { const value = secret.secretJson[key]; if (typeof value === 'string' && value.trim()) return value.trim(); } return ''; } export function requireSecretString(config: TenantAuthProviderConfig, keys: string[], code: string) { const value = secretString(config, keys); if (value) return value; throw new HttpError(503, `${config.provider} secret is missing ${keys[0]}`, code); }