fix: preserve provider auth metadata

This commit is contained in:
elky
2026-06-20 22:11:42 +08:00
parent 1123095bb7
commit f1c30439ff
51 changed files with 2098 additions and 420 deletions
@@ -1455,7 +1455,10 @@ const defaultBodyRulesByFormat = ref<Record<string, BodyRule[]>>({})
const defaultBodyRulesLoaded = ref<Record<string, boolean>>({})
const loadingDefaultBodyRulesByFormat = ref<Record<string, boolean>>({})
// 系统保留的 header 名称(不允许用户设置)
// Endpoint 规则会经过通用 header/body rule 引擎执行。认证头、协议控制头和
// 响应长度这类字段在后端同样是 protected keys;前端也保持同一语义,避免用户
// 以为普通 endpoint rules 能改认证。OAuth/账号导入保留 headers 走 auth_config,
// 不走这里的 endpoint header_rules。
const RESERVED_HEADERS = new Set([
'authorization',
'x-api-key',
@@ -1463,6 +1466,7 @@ const RESERVED_HEADERS = new Set([
'content-type',
'content-length',
'host',
'proxy-authorization',
])
const RESERVED_RESPONSE_HEADERS = new Set([
@@ -1472,7 +1476,6 @@ const RESERVED_RESPONSE_HEADERS = new Set([
const RESPONSE_HEADER_RULES_CONFIG_KEY = 'response_header_rules'
const RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY = 'responseHeaderRules'
// 系统保留的 body 字段名(不允许用户设置)
const RESERVED_BODY_FIELDS = new Set([
'stream',
])
@@ -1539,7 +1542,41 @@ function requireJsonString(rule: Record<string, unknown>, key: string, label: st
return typeof rule[key] === 'string' ? null : `${label}第 ${index + 1} 条:${key} 必须是字符串`
}
function validateHeaderRuleJson(rule: unknown, label: string, index: number): string | null {
function normalizeHeaderRuleName(raw: string): string {
return raw.trim().toLowerCase()
}
function reservedHeaderRuleError(raw: string): string | null {
const name = normalizeHeaderRuleName(raw)
return name && RESERVED_HEADERS.has(name) ? `"${raw}" 是系统保留的请求头` : null
}
function reservedResponseHeaderRuleError(raw: string): string | null {
const name = normalizeHeaderRuleName(raw)
return name && RESERVED_RESPONSE_HEADERS.has(name) ? `"${raw}" 是系统保留的响应头` : null
}
function bodyRuleTopLevelField(rawPath: string): string | null {
const raw = rawPath.trim()
if (!raw) return null
const dotPart = raw.includes('[') ? raw.slice(0, raw.indexOf('[')) : raw
const parts = dotPart ? parseBodyRulePathParts(dotPart) : [raw.split('[')[0] || raw]
return parts?.[0]?.trim().toLowerCase() || null
}
function reservedBodyRuleFieldError(rawPath: string): string | null {
const topField = bodyRuleTopLevelField(rawPath)
return topField && RESERVED_BODY_FIELDS.has(topField)
? `"${topField}" 是系统保留的顶层字段`
: null
}
function validateHeaderRuleJson(
rule: unknown,
label: string,
index: number,
reservedNameError: (raw: string) => string | null = reservedHeaderRuleError
): string | null {
if (!isJsonObject(rule)) return `${label}第 ${index + 1} 条必须是对象`
if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') {
return `${label}第 ${index + 1} 条:enabled 必须是布尔值`
@@ -1550,15 +1587,19 @@ function validateHeaderRuleJson(rule: unknown, label: string, index: number): st
}
if (action === 'set') {
return requireJsonString(rule, 'key', label, index)
|| reservedNameError(rule.key as string)
|| requireJsonString(rule, 'value', label, index)
|| validateJsonCondition(rule, label, index)
}
if (action === 'drop') {
return requireJsonString(rule, 'key', label, index)
|| reservedNameError(rule.key as string)
|| validateJsonCondition(rule, label, index)
}
return requireJsonString(rule, 'from', label, index)
|| requireJsonString(rule, 'to', label, index)
|| reservedNameError(rule.from as string)
|| reservedNameError(rule.to as string)
|| validateJsonCondition(rule, label, index)
}
@@ -1574,26 +1615,32 @@ function validateBodyRuleJson(rule: unknown, label: string, index: number): stri
if (action === 'set' || action === 'append') {
return requireJsonString(rule, 'path', label, index)
|| reservedBodyRuleFieldError(rule.path as string)
|| (Object.prototype.hasOwnProperty.call(rule, 'value') ? null : `${label}第 ${index + 1} 条:value 不能为空`)
|| validateJsonCondition(rule, label, index)
}
if (action === 'drop') {
return requireJsonString(rule, 'path', label, index)
|| reservedBodyRuleFieldError(rule.path as string)
|| validateJsonCondition(rule, label, index)
}
if (action === 'rename') {
return requireJsonString(rule, 'from', label, index)
|| requireJsonString(rule, 'to', label, index)
|| reservedBodyRuleFieldError(rule.from as string)
|| reservedBodyRuleFieldError(rule.to as string)
|| validateJsonCondition(rule, label, index)
}
if (action === 'insert') {
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index)
if (reservedBodyRuleFieldError(rule.path as string)) return reservedBodyRuleFieldError(rule.path as string)
if (!Number.isInteger(rule.index)) return `${label}第 ${index + 1} 条:index 必须是整数`
if (!Object.prototype.hasOwnProperty.call(rule, 'value')) return `${label}第 ${index + 1} 条:value 不能为空`
return validateJsonCondition(rule, label, index)
}
if (action === 'regex_replace') {
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index)
if (reservedBodyRuleFieldError(rule.path as string)) return reservedBodyRuleFieldError(rule.path as string)
if (requireJsonString(rule, 'pattern', label, index)) return requireJsonString(rule, 'pattern', label, index)
if (typeof rule.replacement !== 'string') return `${label}第 ${index + 1} 条:replacement 必须是字符串`
if (rule.flags !== undefined && typeof rule.flags !== 'string') return `${label}第 ${index + 1} 条:flags 必须是字符串`
@@ -1633,7 +1680,12 @@ function parseEndpointRulesJsonDraft(draft: string): { value: EndpointRulesJsonP
if (error) return { value: null, error }
}
for (let i = 0; i < response.value.length; i++) {
const error = validateHeaderRuleJson(response.value[i], 'response_header_rules ', i)
const error = validateHeaderRuleJson(
response.value[i],
'response_header_rules ',
i,
reservedResponseHeaderRuleError,
)
if (error) return { value: null, error }
}
@@ -2334,9 +2386,8 @@ function validateRuleKeyForEndpoint(endpointId: string, key: string, index: numb
const trimmedKey = key.trim().toLowerCase()
if (!trimmedKey) return null
if (RESERVED_HEADERS.has(trimmedKey)) {
return `"${key}" 是系统保留的请求头`
}
const reservedErr = reservedHeaderRuleError(key)
if (reservedErr) return reservedErr
const rules = getEndpointEditRules(endpointId)
const currentRule = rules[index]
@@ -2359,6 +2410,9 @@ function validateRenameFromForEndpoint(endpointId: string, from: string, index:
const trimmedFrom = from.trim().toLowerCase()
if (!trimmedFrom) return null
const reservedErr = reservedHeaderRuleError(from)
if (reservedErr) return reservedErr
const rules = getEndpointEditRules(endpointId)
const currentRule = rules[index]
if (currentRule && !currentRule.enabled) return null
@@ -2380,9 +2434,8 @@ function validateRenameToForEndpoint(endpointId: string, to: string, index: numb
const trimmedTo = to.trim().toLowerCase()
if (!trimmedTo) return null
if (RESERVED_HEADERS.has(trimmedTo)) {
return `"${to}" 是系统保留的请求头`
}
const reservedErr = reservedHeaderRuleError(to)
if (reservedErr) return reservedErr
const rules = getEndpointEditRules(endpointId)
const currentRule = rules[index]
@@ -2525,11 +2578,8 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
return '路径格式无效'
}
// 提取顶层 key(去除数组索引部分)
const topKey = (parts[0] || '').trim().toLowerCase()
if (RESERVED_BODY_FIELDS.has(topKey)) {
return `"${parts[0]}" 是系统保留的顶层字段`
}
const reservedErr = reservedBodyRuleFieldError(raw)
if (reservedErr) return reservedErr
const normalizedPath = raw.toLowerCase()
@@ -2560,10 +2610,8 @@ function validateBodyRenameFromForEndpoint(endpointId: string, from: string, ind
return '路径格式无效(不允许 .a / a. / a..b)'
}
const topKey = (parts[0] || '').trim().toLowerCase()
if (RESERVED_BODY_FIELDS.has(topKey)) {
return `"${parts[0]}" 是系统保留的顶层字段`
}
const reservedErr = reservedBodyRuleFieldError(raw)
if (reservedErr) return reservedErr
const normalizedFrom = raw.toLowerCase()
@@ -2593,10 +2641,8 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
return '路径格式无效(不允许 .a / a. / a..b)'
}
const topKey = (parts[0] || '').trim().toLowerCase()
if (RESERVED_BODY_FIELDS.has(topKey)) {
return `"${parts[0]}" 是系统保留的顶层字段`
}
const reservedErr = reservedBodyRuleFieldError(raw)
if (reservedErr) return reservedErr
const normalizedTo = raw.toLowerCase()
@@ -3095,9 +3141,8 @@ function validateResponseHeaderNameForEndpoint(endpointId: string, name: string,
const trimmedName = name.trim().toLowerCase()
if (!trimmedName) return null
if ((field === 'key' || field === 'to') && RESERVED_RESPONSE_HEADERS.has(trimmedName)) {
return `"${name}" 是系统保留的响应头`
}
const reservedErr = reservedResponseHeaderRuleError(name)
if (reservedErr) return reservedErr
const rules = getEndpointEditResponseRules(endpointId)
const currentRule = rules[index]
@@ -1352,6 +1352,7 @@ function parseImportText(text: string): {
browser_profile?: string
user_id?: string
account_name?: string
headers?: Record<string, string>
} | null {
const trimmed = text.trim()
if (!trimmed) return null
@@ -1422,7 +1423,14 @@ function parseImportText(text: string): {
const normalizedSessionToken = typeof sessionToken === 'string' && sessionToken.trim()
? sessionToken.trim()
: (typeof sessionTokenCamel === 'string' && sessionTokenCamel.trim() ? sessionTokenCamel.trim() : undefined)
const importedAccessToken = normalizedAccessToken ?? grokSsoToken ?? normalizedSessionToken
const normalizedHeaders = normalizeHeadersField(obj.headers)
?? normalizeHeadersField(obj.request_headers)
?? normalizeHeadersField(obj.requestHeaders)
?? normalizeHeadersField(obj.header_overrides)
?? normalizeHeadersField(obj.headerOverrides)
?? normalizeHeadersField(obj.extra_headers)
?? normalizeHeadersField(obj.extraHeaders)
const importedAccessToken = normalizedAccessToken ?? grokSsoToken ?? normalizedSessionToken ?? bearerTokenFromHeaders(normalizedHeaders)
if (normalizedRefreshToken || importedAccessToken) {
return {
refresh_token: normalizedRefreshToken,
@@ -1441,6 +1449,7 @@ function parseImportText(text: string): {
browser_profile: isGrokProvider.value ? normalizeStringField(obj.browser_profile) ?? normalizeStringField(obj.browserProfile) ?? normalizeStringField(obj.browser) ?? normalizeStringField(obj.impersonate) ?? grokCookieImport?.browser_profile : undefined,
user_id: normalizeStringField(obj.user_id) ?? normalizeStringField(obj.userId) ?? normalizeStringField(obj.chatgpt_user_id) ?? normalizeStringField(obj.chatgptUserId),
account_name: normalizeStringField(obj.account_name) ?? normalizeStringField(obj.accountName),
headers: normalizedHeaders,
}
}
return null
@@ -1526,6 +1535,34 @@ function normalizeStringField(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function normalizeHeadersField(value: unknown): Record<string, string> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
const headers: Record<string, string> = {}
for (const [rawKey, rawValue] of Object.entries(value as Record<string, unknown>)) {
const key = rawKey.trim().toLowerCase()
if (!key || ['host', 'content-length', 'connection', 'transfer-encoding', 'proxy-authorization'].includes(key)) {
continue
}
let headerValue: string | undefined
if (typeof rawValue === 'string') {
headerValue = rawValue.trim()
} else if (typeof rawValue === 'number' || typeof rawValue === 'boolean') {
headerValue = String(rawValue)
}
if (headerValue) {
headers[key] = headerValue
}
}
return Object.keys(headers).length > 0 ? headers : undefined
}
function bearerTokenFromHeaders(headers: Record<string, string> | undefined): string | undefined {
const authorization = headers?.authorization?.trim()
if (!authorization) return undefined
const match = authorization.match(/^bearer\s+(.+)$/i)
return match?.[1]?.trim() || undefined
}
function normalizeNumberField(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value)
@@ -323,14 +323,14 @@
</div>
<div class="flex items-center gap-1">
<span class="text-[11px] font-mono text-muted-foreground">
{{ isOAuthManagedCredential(key) ? '[Refresh Token]' : (isServiceAccountCredential(key) ? '[Service Account]' : key.api_key_masked) }}
{{ getProviderMaskedSecretLabel(key, provider.provider_type) }}
</span>
<Button
v-if="canExportOAuthCredential(key)"
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
title="下载 Refresh Token 授权文件"
title="下载 OAuth 授权文件"
@click.stop="downloadRefreshToken(key)"
>
<Download class="w-2.5 h-2.5" />
@@ -1513,6 +1513,7 @@ import {
canRefreshOAuthCredential,
isOAuthManagedCredential,
isServiceAccountCredential,
getProviderMaskedSecretLabel,
shouldShowOAuthRefreshControl,
} from '@/utils/providerKeyAuth'
import {
@@ -260,6 +260,11 @@ function getButton(root: HTMLElement, text: string) {
.find(button => button.textContent?.includes(text))
}
function getExactButton(root: HTMLElement, text: string) {
return Array.from(root.querySelectorAll('button'))
.find(button => button.textContent?.trim() === text)
}
function getImportTextarea(root: HTMLElement) {
const textarea = root.querySelector('textarea')
if (!(textarea instanceof HTMLTextAreaElement)) {
@@ -350,9 +355,40 @@ describe('OAuthAccountDialog Grok import', () => {
account_id: undefined,
account_user_id: undefined,
user_id: undefined,
headers: undefined,
})
})
it('keeps Codex imported request headers on single JSON import', async () => {
const root = mountDialog('codex')
await settle()
getButton(root, '导入授权')?.click()
await settle()
const textarea = getImportTextarea(root)
textarea.value = JSON.stringify({
access_token: 'jwt-access-token',
headers: {
authorization: 'Bearer session-token',
},
accountId: 'acct-1',
})
textarea.dispatchEvent(new Event('input'))
await settle()
getExactButton(root, '导入')?.click()
await settle()
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', expect.objectContaining({
access_token: 'jwt-access-token',
headers: {
authorization: 'Bearer session-token',
},
account_id: 'acct-1',
}))
})
it('keeps Grok multiline token import on the batch task path', async () => {
const root = mountDialog('grok')
await settle()