Files
gongxue-base/apps/api/src/features/auth/provider-config.ts
2026-06-28 22:19:46 +08:00

193 lines
6.3 KiB
TypeScript

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<string, unknown>;
}
export interface TenantAuthProviderConfig {
tenantId: string;
provider: string;
status: 'active' | 'testing' | 'disabled';
configPublic: Record<string, unknown>;
secret: TenantSecretConfig | null;
}
interface TenantAuthProviderRow {
provider: string;
status: 'active' | 'testing' | 'disabled';
configPublic: Record<string, unknown> | null;
}
interface TenantSecretRow {
secretScope: TenantSecretScope;
secretKey: string;
secretValue: string | null;
secretJson: Record<string, unknown> | null;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
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<string, unknown>,
provider: string,
): Promise<TenantSecretConfig | null> {
const secretRef = parseSecretRef(configPublic.secretRef);
const scope = secretRef?.scope || defaultSecretScope(provider);
const key = secretRef?.key || provider;
const row = await queryOne<TenantSecretRow>(
`
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<TenantAuthProviderConfig | null> {
const uniqueProviders = [...new Set(providers.map(item => item.trim()).filter(Boolean))];
if (uniqueProviders.length === 0) return null;
const rows = await query<TenantAuthProviderRow>(
`
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);
}