feat(oauth-gateway): add oauthGatewayUrl configuration and handling

- Introduced a new configuration for oauthGatewayUrl in system settings, allowing for flexible OAuth callback handling.
- Updated the SystemSettingService to parse and normalize the oauthGatewayUrl.
- Enhanced the AuthProvider to utilize the oauthGatewayUrl for constructing redirect URIs for third-party logins.
- Added validation for the oauthGatewayUrl to ensure it adheres to HTTP/HTTPS protocols.
- Updated UI schema to include oauthGatewayUrl field for user input.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-12 17:14:04 +08:00
parent 74584cd230
commit 66c57218e4
7 changed files with 104 additions and 41 deletions

View File

@@ -4,6 +4,10 @@ import { z } from 'zod'
const nonEmptyString = z.string().trim().min(1)
const nullableNonEmptyString = nonEmptyString.nullable()
const nullableUrl = z.string().trim().url({ message: '必须是有效的 URL' }).nullable()
const nullableHttpUrl = nullableUrl.refine(
(value) => value === null || value.startsWith('http://') || value.startsWith('https://'),
{ message: '只支持 http 或 https 协议' },
)
export const SYSTEM_SETTING_DEFINITIONS = {
allowRegistration: {
@@ -36,6 +40,12 @@ export const SYSTEM_SETTING_DEFINITIONS = {
defaultValue: DEFAULT_BASE_DOMAIN,
isSensitive: false,
},
oauthGatewayUrl: {
key: 'system.auth.oauth.gatewayUrl',
schema: nullableHttpUrl,
defaultValue: null as string | null,
isSensitive: false,
},
oauthGoogleClientId: {
key: 'system.auth.oauth.google.clientId',
schema: nullableNonEmptyString,

View File

@@ -53,6 +53,14 @@ export class SystemSettingService {
const baseDomain = baseDomainRaw.trim().toLowerCase()
const oauthGatewayUrl = this.normalizeGatewayUrl(
this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGatewayUrl.key],
SYSTEM_SETTING_DEFINITIONS.oauthGatewayUrl.schema,
SYSTEM_SETTING_DEFINITIONS.oauthGatewayUrl.defaultValue,
),
)
const oauthGoogleClientId = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.key],
SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.schema,
@@ -94,6 +102,7 @@ export class SystemSettingService {
maxRegistrableUsers,
localProviderEnabled,
baseDomain,
oauthGatewayUrl,
oauthGoogleClientId,
oauthGoogleClientSecret,
oauthGoogleRedirectUri,
@@ -165,6 +174,12 @@ export class SystemSettingService {
enqueueUpdate('baseDomain', sanitized)
}
}
if (patch.oauthGatewayUrl !== undefined) {
const sanitized = this.normalizeGatewayUrl(patch.oauthGatewayUrl)
if (sanitized !== current.oauthGatewayUrl) {
enqueueUpdate('oauthGatewayUrl', sanitized)
}
}
if (patch.oauthGoogleClientId !== undefined) {
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientId)
@@ -247,11 +262,16 @@ export class SystemSettingService {
}
}
async getAuthModuleConfig(): Promise<{ baseDomain: string; socialProviders: SocialProvidersConfig }> {
async getAuthModuleConfig(): Promise<{
baseDomain: string
socialProviders: SocialProvidersConfig
oauthGatewayUrl: string | null
}> {
const settings = await this.getSettings()
return {
baseDomain: settings.baseDomain,
socialProviders: this.buildSocialProviders(settings),
oauthGatewayUrl: settings.oauthGatewayUrl,
}
}
@@ -294,6 +314,29 @@ export class SystemSettingService {
return trimmed.length > 0 ? trimmed : null
}
private normalizeGatewayUrl(value: string | null | undefined): string | null {
if (value === undefined || value === null) {
return null
}
const trimmed = value.trim()
if (trimmed.length === 0) {
return null
}
try {
const url = new URL(trimmed)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null
}
const normalizedPath = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '')
return `${url.origin}${normalizedPath}`
} catch {
return null
}
}
private normalizeRedirectPath(value: string | null | undefined): string | null {
if (value === undefined || value === null) {
return null

View File

@@ -1,4 +1,5 @@
import type { UiSchema } from '../ui-schema/ui-schema.type'
import type { UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
import type { SystemSettingField } from './system-setting.constants'
export interface SystemSettings {
@@ -6,6 +7,7 @@ export interface SystemSettings {
maxRegistrableUsers: number | null
localProviderEnabled: boolean
baseDomain: string
oauthGatewayUrl: string | null
oauthGoogleClientId: string | null
oauthGoogleClientSecret: string | null
oauthGoogleRedirectUri: string | null

View File

@@ -1,7 +1,8 @@
import type { UiNode, UiSchema } from '../ui-schema/ui-schema.type'
import type { UiNode, UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
import type { SystemSettingField } from './system-setting.constants'
export const SYSTEM_SETTING_UI_SCHEMA_VERSION = '1.1.0'
export const SYSTEM_SETTING_UI_SCHEMA_VERSION = '1.2.0'
export const SYSTEM_SETTING_UI_SCHEMA: UiSchema<SystemSettingField> = {
version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
@@ -69,6 +70,18 @@ export const SYSTEM_SETTING_UI_SCHEMA: UiSchema<SystemSettingField> = {
description: '统一配置所有租户可用的第三方登录渠道。',
icon: 'shield-check',
children: [
{
type: 'field',
id: 'oauth-gateway-url',
title: 'OAuth 网关地址',
description: '统一的 OAuth 回调入口,例如 https://auth.afilmory.art。留空则直接回调到租户域名。',
helperText: '必须包含 http/https 协议,结尾无需斜杠。',
key: 'oauthGatewayUrl',
component: {
type: 'text',
placeholder: 'https://auth.afilmory.art',
},
},
{
type: 'group',
id: 'oauth-google',

View File

@@ -17,6 +17,7 @@ export interface AuthModuleOptions {
useDrizzle: boolean
socialProviders: SocialProvidersConfig
baseDomain: string
oauthGatewayUrl: string | null
}
@injectable()
@@ -25,13 +26,14 @@ export class AuthConfig {
async getOptions(): Promise<AuthModuleOptions> {
const prefix = '/auth'
const { socialProviders, baseDomain } = await this.systemSettings.getAuthModuleConfig()
const { socialProviders, baseDomain, oauthGatewayUrl } = await this.systemSettings.getAuthModuleConfig()
return {
prefix,
useDrizzle: true,
socialProviders,
baseDomain,
oauthGatewayUrl,
}
}
}

View File

@@ -142,11 +142,9 @@ export class AuthProvider implements OnModuleInit {
}
private buildBetterAuthProvidersForHost(
host: string,
fallbackHost: string,
protocol: string,
tenantSlug: string | null,
providers: SocialProvidersConfig,
oauthGatewayUrl: string | null,
): Record<string, { clientId: string; clientSecret: string; redirectUri?: string }> {
const entries: Array<[keyof SocialProvidersConfig, SocialProviderOptions]> = Object.entries(providers).filter(
(entry): entry is [keyof SocialProvidersConfig, SocialProviderOptions] => Boolean(entry[1]),
@@ -154,7 +152,7 @@ export class AuthProvider implements OnModuleInit {
return entries.reduce<Record<string, { clientId: string; clientSecret: string; redirectURI?: string }>>(
(acc, [key, value]) => {
const redirectUri = this.buildRedirectUri(protocol, host, fallbackHost, tenantSlug, key, value)
const redirectUri = this.buildRedirectUri(tenantSlug, key, value, oauthGatewayUrl)
acc[key] = {
clientId: value.clientId,
clientSecret: value.clientSecret,
@@ -167,55 +165,41 @@ export class AuthProvider implements OnModuleInit {
}
private buildRedirectUri(
protocol: string,
host: string,
fallbackHost: string,
tenantSlug: string | null,
provider: keyof SocialProvidersConfig,
options: SocialProviderOptions,
oauthGatewayUrl: string | null,
): string | null {
const basePath = options.redirectPath ?? `/api/auth/callback/${provider}`
if (!basePath.startsWith('/')) {
return null
}
const redirectHost = this.resolveRedirectHost(host, fallbackHost, tenantSlug)
return `${protocol}://${redirectHost}${basePath}`
if (oauthGatewayUrl) {
return this.buildGatewayRedirectUri(oauthGatewayUrl, basePath, tenantSlug)
}
logger.error(
['[AuthProvider] OAuth 网关地址未配置,无法为第三方登录生成回调 URL。', `provider=${String(provider)}`].join(' '),
)
return null
}
private resolveRedirectHost(host: string, fallbackHost: string, tenantSlug: string | null): string {
const normalizedSlug = tenantSlug?.trim().toLowerCase()
if (!normalizedSlug) {
return host
private buildGatewayRedirectUri(gatewayBaseUrl: string, basePath: string, tenantSlug: string | null): string {
const normalizedBase = gatewayBaseUrl.replace(/\/+$/, '')
const searchParams = new URLSearchParams()
if (tenantSlug) {
searchParams.set('tenantSlug', tenantSlug)
}
const [hostName, hostPort] = host.split(':') as [string, string?]
if (!hostName.toLowerCase().startsWith(`${normalizedSlug}.`)) {
return host
}
const [fallbackNameRaw, fallbackPort] = fallbackHost.split(':') as [string, string?]
const fallbackName = fallbackNameRaw?.trim()
if (!fallbackName) {
return host
}
const portSegment = hostPort ?? fallbackPort ?? ''
return portSegment ? `${fallbackName}:${portSegment}` : fallbackName
const query = searchParams.toString()
return `${normalizedBase}${basePath}${query ? `?${query}` : ''}`
}
private async createAuthForEndpoint(
host: string,
protocol: string,
tenantSlug: string | null,
): Promise<BetterAuthInstance> {
private async createAuthForEndpoint(tenantSlug: string | null): Promise<BetterAuthInstance> {
const options = await this.getModuleOptions()
const db = this.drizzleProvider.getDb()
const socialProviders = this.buildBetterAuthProvidersForHost(
host,
options.baseDomain,
protocol,
tenantSlug,
options.socialProviders,
options.oauthGatewayUrl,
)
const cookiePrefix = this.buildCookiePrefix(tenantSlug)
@@ -350,7 +334,7 @@ export class AuthProvider implements OnModuleInit {
const cacheKey = `${protocol}://${host}::${slugKey}`
if (!this.instances.has(cacheKey)) {
const instancePromise = this.createAuthForEndpoint(host, protocol, tenantSlug).then((instance) => {
const instancePromise = this.createAuthForEndpoint(tenantSlug).then((instance) => {
logger.info(`Better Auth initialized for ${cacheKey}`)
return instance
})

View File

@@ -19,6 +19,15 @@ const updateSuperAdminSettingsSchema = z
.min(1)
.regex(/^[a-z0-9.-]+$/i, { message: '无效的基础域名' })
.optional(),
oauthGatewayUrl: z
.string()
.trim()
.url({ message: '必须是有效的 URL' })
.nullable()
.refine((value) => value === null || value.startsWith('http://') || value.startsWith('https://'), {
message: '仅支持 http 或 https 协议',
})
.optional(),
oauthGoogleClientId: z.string().trim().min(1).nullable().optional(),
oauthGoogleClientSecret: z.string().trim().min(1).nullable().optional(),
oauthGoogleRedirectUri: redirectPathInputSchema.nullable().optional(),