forked from wangziqi/gongxue-base
feat: add china auth providers
This commit is contained in:
192
apps/api/src/features/auth/provider-config.ts
Normal file
192
apps/api/src/features/auth/provider-config.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user