fix(auth): restrict credential actions to OAuth files
This commit is contained in:
1 parent
efe9b4b936
commit
892b14c2ab
9 files changed
+156
-27
No files matched your search
+5
-4
@@ -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': '認証ファイルのモデル除外ルールが無効です。ファイルを確認してください',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '凭证文件的模型排除规则格式无效,请先检查该文件',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:/);
|
||||
|
||||
Reference in new issue
Block a user