fix(auth): restrict credential actions to OAuth files

This commit is contained in:
lzt404 committed 2026-09-20 18:09:32 +08:00
1 parent efe9b4b936
commit 892b14c2ab
9 files changed
+156 -27

No files matched your search

+5 -4
View File
@@ -854,7 +854,7 @@ export const jaOverrides = {
'authFiles.quota.resets': '手動リセット {count} 回',
'authFiles.quota.expiry': '最も早い有効期限 {time}',
'authFiles.models.noneForProvider': 'このプロバイダーには設定可能なモデル定義がありません',
'authFiles.models.updated': '{provider} 全体のモデル設定を更新しました',
'authFiles.models.updated': '{provider} の OAuth 認証情報のモデル除外ルールを更新しました',
'authFiles.priority.button': '優先度 {priority}',
'authFiles.priority.title': '認証情報の優先度を変更',
'authFiles.priority.label': '優先度',
@@ -901,9 +901,10 @@ export const jaOverrides = {
'authFiles.models.title': 'アカウントのモデル除外',
'authFiles.models.description': '{provider} · チェックしたモデルをこのアカウントで除外します。全体のルールも適用されます',
'authFiles.models.credentialUpdated': '{name} のモデル除外ルールを更新しました',
'authFiles.models.globalButton': 'OAuth 全体のモデル除外',
'authFiles.models.provider': 'プロバイダー',
'authFiles.models.globalDescription': 'チェックしたモデルをすべての {provider} OAuth アカウントで除外します。個別のルールも適用されます',
'authFiles.models.globalButton': 'OAuth 認証情報のモデル除外',
'authFiles.models.provider': 'OAuth プロバイダー',
'authFiles.models.globalDescription': 'チェックしたモデルはすべての {provider} OAuth 認証情報ファイルでのみ除外され、API 接続には影響しません。認証情報ごとのルールも適用されます',
'authFiles.fileOnly': 'この操作は OAuth 認証情報ファイルのみが対象です。API 接続は「API 接続」ページで管理してください。',
'authFiles.models.runtimeUnsupported': '実行時の認証情報のモデルは、対応するプロバイダーまたは認証元で設定してください',
'authFiles.models.invalidMetadata': '認証ファイルを読み取れないため、モデル設定を読み込めませんでした',
'authFiles.models.invalidExclusions': '認証ファイルのモデル除外ルールが無効です。ファイルを確認してください',
+5 -4
View File
@@ -854,7 +854,7 @@ export const en: Record<MessageKey, string> = {
'authFiles.quota.resets': '{count} manual resets',
'authFiles.quota.expiry': 'Earliest expiry {time}',
'authFiles.models.noneForProvider': 'This provider has no configurable model definitions',
'authFiles.models.updated': 'Provider-wide models updated for {provider}',
'authFiles.models.updated': 'OAuth credential model exclusions updated for {provider}',
'authFiles.priority.button': 'Priority {priority}',
'authFiles.priority.title': 'Edit Credential Priority',
'authFiles.priority.label': 'Priority',
@@ -901,9 +901,10 @@ export const en: Record<MessageKey, string> = {
'authFiles.models.title': 'Account Model Exclusions',
'authFiles.models.description': '{provider} · Checked models are excluded by this account; provider-wide rules also apply',
'authFiles.models.credentialUpdated': 'Model exclusions updated for {name}',
'authFiles.models.globalButton': 'OAuth Global Model Exclusions',
'authFiles.models.provider': 'Provider',
'authFiles.models.globalDescription': 'Checked models are excluded for every {provider} OAuth account; account-specific rules still apply',
'authFiles.models.globalButton': 'OAuth Credential Model Exclusions',
'authFiles.models.provider': 'OAuth Provider',
'authFiles.models.globalDescription': 'Checked models are excluded only for all {provider} OAuth credential files, not API Access entries; credential-specific rules still apply',
'authFiles.fileOnly': 'This action only supports OAuth credential files. Manage API Access entries on the API Access page.',
'authFiles.models.runtimeUnsupported': 'Manage models for runtime credentials in their provider or authentication source',
'authFiles.models.invalidMetadata': 'Could not read the credential file; model settings were not loaded',
'authFiles.models.invalidExclusions': 'The credential file has invalid model exclusion rules; check the file first',
+5 -4
View File
@@ -853,7 +853,7 @@ export const zhCN = {
'authFiles.quota.resets': '主动重置 {count} 次',
'authFiles.quota.expiry': '最早过期 {time}',
'authFiles.models.noneForProvider': '该提供商没有可配置的模型定义',
'authFiles.models.updated': '{provider} 渠道全局模型已更新',
'authFiles.models.updated': '{provider} OAuth 凭证模型禁用规则已更新',
'authFiles.priority.button': '优先级 {priority}',
'authFiles.priority.title': '修改凭证优先级',
'authFiles.priority.label': '优先级',
@@ -900,9 +900,10 @@ export const zhCN = {
'authFiles.models.title': '账号排除模型',
'authFiles.models.description': '{provider} · 勾选表示此账号主动排除;渠道全局规则仍会叠加生效',
'authFiles.models.credentialUpdated': '{name} 的模型排除规则已更新',
'authFiles.models.globalButton': 'OAuth 全局模型禁用',
'authFiles.models.provider': '渠道',
'authFiles.models.globalDescription': '勾选将禁用全部 {provider} OAuth 账号的对应模型;账号自身规则仍然生效',
'authFiles.models.globalButton': 'OAuth 凭证模型禁用',
'authFiles.models.provider': 'OAuth 渠道',
'authFiles.models.globalDescription': '勾选仅禁用全部 {provider} OAuth 凭证文件的对应模型,不影响 API 接入;凭证自身规则仍然生效',
'authFiles.fileOnly': '此操作仅支持 OAuth 凭证文件;API 接入请在“API 接入”页面管理。',
'authFiles.models.runtimeUnsupported': '运行时凭据请在对应提供商或认证源中设置模型',
'authFiles.models.invalidMetadata': '无法读取凭证文件内容,未加载模型设置',
'authFiles.models.invalidExclusions': '凭证文件的模型排除规则格式无效,请先检查该文件',
+9 -13
View File
@@ -55,8 +55,11 @@ import {
import {
authFileName,
dedupeAuthFiles,
isOAuthCredentialFile,
isRuntimeOnlyAuthFile,
oauthModelProvidersFromAuthFiles,
parseAuthFilePriority,
setOAuthCredentialFileDisabled,
} from '../services/authFiles';
import {
modelMatchesRule,
@@ -140,12 +143,9 @@ export function AuthFileManagementPage() {
const oauthModels = oauthModelSettings?.models ?? [];
const oauthExcludedRules = normalizeOAuthExcludedRules(oauthExcludedRulesText.split(/\r?\n/));
const excludedOauthModelCount = oauthModels.length - openOAuthModelNames(oauthModels, oauthExcludedRules).size;
const oauthModelProviders = useMemo(() => Array.from(new Map(
files.filter((file) => providerKey(file)).map((file) => [providerKey(file), {
provider: providerKey(file),
label: providerName(file),
}]),
).values()).sort((a, b) => a.label.localeCompare(b.label)), [files]);
const oauthModelProviders = useMemo(() => oauthModelProvidersFromAuthFiles(files)
.map((provider) => ({ provider, label: providerName({ provider }) }))
.sort((a, b) => a.label.localeCompare(b.label)), [files]);
const loadFiles = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true);
@@ -306,15 +306,11 @@ export function AuthFileManagementPage() {
};
const toggleStatus = async (file: AuthFile) => {
const name = fileName(file);
feedback.clearNotice();
setBusy(true);
setError('');
try {
await managementApi.patch('/auth-files/status', {
name,
disabled: !readBoolean(file, 'disabled'),
});
await setOAuthCredentialFileDisabled(file, !readBoolean(file, 'disabled'));
await loadFiles(false);
} catch (requestError) {
setError(String(requestError));
@@ -447,10 +443,10 @@ export function AuthFileManagementPage() {
</div>
{note ? <div className="auth-card-note"><span>{t('authFiles.settings.note')}</span><p title={note}>{note}</p></div> : null}
<footer className="auth-card-actions">
<button type="button" className="secondary-button compact-button" onClick={() => setSettingsName(name)} disabled={busy || isRuntimeOnly(file) || !readString(file, 'name')} title={t(isRuntimeOnly(file) ? 'authFiles.settings.runtime' : 'authFiles.settings.title')}><Settings2 size={14} />{t('authFiles.settings.button')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => setSettingsName(name)} disabled={busy || !isOAuthCredentialFile(file)} title={t(isOAuthCredentialFile(file) ? 'authFiles.settings.title' : 'authFiles.fileOnly')}><Settings2 size={14} />{t('authFiles.settings.button')}</button>
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')}>{copied === name ? <Check size={15} /> : <Copy size={15} />}</button>
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`} onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? t('common.enable') : t('common.disable')}</button>
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`} onClick={() => void toggleStatus(file)} disabled={busy || !isOAuthCredentialFile(file)} title={isOAuthCredentialFile(file) ? undefined : t('authFiles.fileOnly')}>{disabled ? t('common.enable') : t('common.disable')}</button>
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={15} /></button>
</footer>
</article>
+30 -1
View File
@@ -1,4 +1,4 @@
import { readBoolean, readString } from './managementApi';
import { managementApi, readBoolean, readString } from './managementApi';
import { getCurrentLocale, translate } from '../i18n';
export type AuthFileRecord = Record<string, unknown>;
@@ -10,6 +10,26 @@ export const authFileName = (file: AuthFileRecord) =>
export const isRuntimeOnlyAuthFile = (file: AuthFileRecord) =>
readBoolean(file, 'runtime_only', 'runtimeOnly');
export const isOAuthCredentialFile = (file: AuthFileRecord): boolean => {
if (!readString(file, 'name').toLowerCase().endsWith('.json') || isRuntimeOnlyAuthFile(file)) return false;
const kinds = ['account_type', 'auth_kind', 'authKind'].map((key) =>
readString(file, key).toLowerCase().replace(/[-_]/g, ''));
if (kinds.includes('apikey')) return false;
const source = readString(file, 'source').toLowerCase();
return !source || source === 'file';
};
export const setOAuthCredentialFileDisabled = async (
file: AuthFileRecord,
disabled: boolean,
api: { patch: (path: string, body: Record<string, unknown>) => Promise<unknown> } = managementApi,
): Promise<void> => {
if (!isOAuthCredentialFile(file)) {
throw new Error(translate(getCurrentLocale(), 'authFiles.fileOnly'));
}
await api.patch('/auth-files/status', { name: readString(file, 'name'), disabled });
};
export const parseAuthFilePriority = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isSafeInteger(value) ? value : undefined;
@@ -31,6 +51,7 @@ const normalizeOAuthProvider = (value: string) => {
const provider = value.trim().toLowerCase();
if (provider === 'cognition') return 'devin';
if (provider === 'anthropic') return 'claude';
if (provider === 'anti-gravity') return 'antigravity';
if (provider === 'openai') return 'codex';
return provider;
};
@@ -38,6 +59,9 @@ const normalizeOAuthProvider = (value: string) => {
const authFileProvider = (file: AuthFileRecord) =>
normalizeOAuthProvider(readString(file, 'provider', 'type'));
export const oauthModelProvidersFromAuthFiles = (files: AuthFileRecord[]): string[] =>
[...new Set(files.filter(isOAuthCredentialFile).map(authFileProvider).filter(Boolean))].sort();
export const snapshotAuthFiles = (files: AuthFileRecord[]): AuthFileSnapshot => {
const grouped = new Map<string, string[]>();
files.forEach((file) => {
@@ -99,6 +123,10 @@ const authFilePriority = (file: AuthFileRecord) => {
return score;
};
const authFileSourceFields = new Set([
'source', 'path', 'runtime_only', 'runtimeOnly', 'account_type', 'auth_kind', 'authKind',
]);
const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => {
const sorted = [...entries].sort((left, right) => {
const priority = authFilePriority(right) - authFilePriority(left);
@@ -111,6 +139,7 @@ const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => {
const merged = { ...sorted[0] };
sorted.slice(1).forEach((entry) => {
Object.entries(entry).forEach(([key, value]) => {
if (authFileSourceFields.has(key)) return;
if (key === 'cooldowns' && Object.prototype.hasOwnProperty.call(merged, key)) return;
if (!hasMeaningfulValue(merged[key]) && hasMeaningfulValue(value)) merged[key] = value;
});
+71
View File
@@ -2,8 +2,11 @@ import { describe, expect, it } from 'bun:test';
import {
changedOAuthAuthFileNames,
dedupeAuthFiles,
isOAuthCredentialFile,
normalizeAuthFilePriorityInput,
oauthModelProvidersFromAuthFiles,
parseAuthFilePriority,
setOAuthCredentialFileDisabled,
snapshotAuthFiles,
} from '../src/services/authFiles';
@@ -14,6 +17,7 @@ describe('认证文件列表规范化', () => {
name: 'codex-user.json',
provider: 'codex',
runtime_only: true,
account_type: 'api_key',
auth_index: 'runtime-index',
email: 'user@example.com',
},
@@ -32,6 +36,73 @@ describe('认证文件列表规范化', () => {
expect(files[0].path).toBe('/tmp/codex-user.json');
expect(files[0].email).toBe('user@example.com');
expect(files[0].auth_index).toBe('runtime-index');
expect(files[0].runtime_only).toBeUndefined();
expect(files[0].account_type).toBeUndefined();
expect(isOAuthCredentialFile(files[0])).toBe(true);
});
});
const oauthFile = { name: 'codex-user.json', provider: 'codex', source: 'file', account_type: 'oauth' };
const nonOAuthFiles = [
{ ...oauthFile, runtime_only: true },
{ ...oauthFile, runtimeOnly: true },
{ ...oauthFile, account_type: 'api_key' },
{ ...oauthFile, account_type: 'API-KEY' },
{ ...oauthFile, auth_kind: 'apikey' },
{ ...oauthFile, authKind: 'api_key' },
{ ...oauthFile, source: 'memory' },
{ ...oauthFile, source: 'config:codex[key]' },
{ ...oauthFile, name: 'codex:apikey:runtime-id' },
{ ...oauthFile, name: '' },
];
describe('OAuth credential file boundaries', () => {
it('accepts disk-backed OAuth files, disabled files, and legacy disk listings', () => {
expect(isOAuthCredentialFile(oauthFile)).toBe(true);
expect(isOAuthCredentialFile({ ...oauthFile, disabled: true })).toBe(true);
expect(isOAuthCredentialFile({ name: 'legacy.JSON', type: 'codex' })).toBe(true);
expect(isOAuthCredentialFile({})).toBe(false);
});
it('rejects API-key and runtime records even when they use an OAuth provider or JSON name', () => {
for (const file of nonOAuthFiles) expect(isOAuthCredentialFile(file)).toBe(false);
});
it('offers only providers with OAuth credential files, preserving aliases and plugin providers', () => {
expect(oauthModelProvidersFromAuthFiles([
...nonOAuthFiles.map((file) => ({ ...file, provider: 'api-only' })),
oauthFile,
{ ...oauthFile, name: 'second.json' },
{ name: 'claude.json', type: 'anthropic' },
{ name: 'devin.json', type: 'cognition' },
{ name: 'antigravity.json', type: 'anti-gravity' },
{ name: 'openai.json', type: 'openai' },
{ name: 'plugin.json', provider: 'custom-oauth', source: 'file' },
{ name: 'unknown.json' },
])).toEqual(['antigravity', 'claude', 'codex', 'custom-oauth', 'devin']);
expect(oauthModelProvidersFromAuthFiles(nonOAuthFiles)).toEqual([]);
});
it('enables and disables only the selected OAuth file without touching API configuration', async () => {
const writes: unknown[] = [];
const api = { patch: async (path: string, body: Record<string, unknown>) => { writes.push({ path, body }); } };
await setOAuthCredentialFileDisabled(oauthFile, true, api);
await setOAuthCredentialFileDisabled({ ...oauthFile, disabled: true }, false, api);
expect(writes).toEqual([
{ path: '/auth-files/status', body: { name: 'codex-user.json', disabled: true } },
{ path: '/auth-files/status', body: { name: 'codex-user.json', disabled: false } },
]);
});
it('rejects runtime and API-key status changes before any management request', async () => {
const writes: unknown[] = [];
const api = { patch: async (path: string, body: Record<string, unknown>) => { writes.push({ path, body }); } };
for (const file of nonOAuthFiles) {
for (const disabled of [true, false]) {
await expect(setOAuthCredentialFileDisabled(file, disabled, api)).rejects.toThrow('OAuth');
}
}
expect(writes).toEqual([]);
});
});
+11
View File
@@ -25,6 +25,17 @@ describe('i18n', () => {
expect(Object.values(zhTW).filter((message) => /認證(?:檔案|文件)/.test(message))).toEqual([]);
});
it('makes the OAuth-only model exclusion scope explicit in every locale', () => {
for (const locale of supportedLocales) {
const description = translate(locale, 'authFiles.models.globalDescription', { provider: 'Codex' });
expect(description).toContain('Codex');
expect(description).toContain('OAuth');
expect(description).toContain('API');
expect(translate(locale, 'authFiles.fileOnly')).toContain('API');
}
expect(translate('zh-CN', 'authFiles.models.globalDescription', { provider: 'Codex' })).toContain('不影响 API 接入');
});
it('normalizes English variants and keeps Chinese as the fallback', () => {
expect(normalizeLocale('en-US')).toBe('en');
expect(normalizeLocale('en')).toBe('en');
+19
View File
@@ -135,6 +135,25 @@ describe('OAuth model settings scopes', () => {
expect(deletes).toHaveLength(1);
});
it('uses only the OAuth exclusion endpoint even when excluding every model for a provider', async () => {
const { api, files, globalRules, writes, deletes, reads } = createApi();
const beforeFiles = structuredClone(files);
await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), ['*'], api);
expect(reads).toEqual([
{ path: '/model-definitions/codex', query: undefined },
{ path: '/oauth-excluded-models', query: undefined },
]);
expect(writes).toEqual([
{ path: '/oauth-excluded-models', body: { provider: 'codex', models: ['*'] } },
]);
expect(globalRules).toEqual({ codex: ['*'], claude: ['claude-old-*'] });
expect(files).toEqual(beforeFiles);
await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), [], api);
expect(deletes).toEqual([{ path: '/oauth-excluded-models', query: { provider: 'codex' } }]);
expect(writes).toHaveLength(1);
expect(files).toEqual(beforeFiles);
});
it('does not rewrite unchanged account rules, including legacy metadata', async () => {
const { api, files, writes } = createApi();
delete files['a.json'].excluded_models;
+1 -1
View File
@@ -58,7 +58,7 @@ describe('控件自身反馈', () => {
expect(attributes?.find((attribute) => attribute.name.getText(source) === 'className')?.initializer?.getText(source))
.toBe("{`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`}");
expect(attributes?.find((attribute) => attribute.name.getText(source) === 'disabled')?.initializer?.getText(source))
.toBe('{busy}');
.toBe('{busy || !isOAuthCredentialFile(file)}');
const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8');
expect(styles).toMatch(/\.real-auth-file-row\.disabled\s*>\s*:not\(\.auth-file-actions\)\s*\{\s*opacity:\s*0\.68;/);
expect(styles).not.toMatch(/\.real-auth-file-row\.disabled\s*\{[^}]*opacity:/);