refactor(quota): secure managed provider credentials (#2160)
- add shared owner-only credential storage for OpenCode Go, Ollama Cloud, and Cursor - validate credentials before atomic writes using 0700 directories and 0600 files - replace provider-specific credential routes with an allowlisted lifecycle API - stop automatically reading Ollama's legacy cookie file - stop reading or modifying Cursor's database during regular quota requests - add explicit one-time Cursor credential import without mutating Cursor storage - persist refreshed Cursor credentials only in OpenChamber-managed storage - add Ollama Cloud and Cursor credential controls to provider settings - preserve OpenCode Go tracking through the shared credential flow - add VS Code credential management and Cursor quota parity - reject authentication redirects, enforce request timeouts, and fail on unparseable usage pages - mask stored secrets in API responses and extend quota security coverage - update quota provider documentation
This commit is contained in:
committed by
GitHub
parent
3b92d97795
commit
b09614fd68
@@ -1,7 +1,8 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { readManagedCredential, writeManagedCredential } from '../credentials/providers.js';
|
||||
import {
|
||||
buildResult,
|
||||
formatMoney,
|
||||
@@ -54,25 +55,6 @@ const readStateValue = (key) => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeStateValue = (key, value) => {
|
||||
if (!existsSync(STATE_DB)) return false;
|
||||
try {
|
||||
const escaped = String(value).replace(/'/g, "''");
|
||||
const escapedKey = String(key).replace(/'/g, "''");
|
||||
execFileSync('sqlite3', [
|
||||
STATE_DB,
|
||||
`INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('${escapedKey}', '${escaped}');`
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore']
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readFileToken = (path) => {
|
||||
try {
|
||||
if (!path || !existsSync(path)) return null;
|
||||
@@ -83,16 +65,6 @@ const readFileToken = (path) => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeFileToken = (path, value) => {
|
||||
try {
|
||||
if (!path) return false;
|
||||
writeFileSync(path, `${value}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuthState = () => {
|
||||
const envAccessToken = process.env.CURSOR_TOKEN || process.env.CURSOR_ACCESS_TOKEN || null;
|
||||
const envRefreshToken = process.env.CURSOR_REFRESH_TOKEN || null;
|
||||
@@ -113,15 +85,15 @@ const loadAuthState = () => {
|
||||
return {
|
||||
accessToken: fileAccessToken,
|
||||
refreshToken: fileRefreshToken,
|
||||
source: 'file',
|
||||
accessTokenPath
|
||||
source: 'file'
|
||||
};
|
||||
}
|
||||
|
||||
const managed = readManagedCredential(providerId);
|
||||
return {
|
||||
accessToken: readStateValue('cursorAuth/accessToken'),
|
||||
refreshToken: readStateValue('cursorAuth/refreshToken'),
|
||||
source: 'sqlite'
|
||||
accessToken: managed?.accessToken || null,
|
||||
refreshToken: managed?.refreshToken || null,
|
||||
source: 'managed'
|
||||
};
|
||||
};
|
||||
|
||||
@@ -133,8 +105,18 @@ const tokenNeedsRefresh = (token) => {
|
||||
};
|
||||
|
||||
const persistAccessToken = (auth, accessToken) => {
|
||||
if (auth.source === 'sqlite') writeStateValue('cursorAuth/accessToken', accessToken);
|
||||
if (auth.source === 'file') writeFileToken(auth.accessTokenPath, accessToken);
|
||||
if (auth.source === 'managed') writeManagedCredential(providerId, { accessToken, refreshToken: auth.refreshToken || '' });
|
||||
};
|
||||
|
||||
export const importCursorCredential = async () => {
|
||||
const credential = {
|
||||
accessToken: readStateValue('cursorAuth/accessToken') || '',
|
||||
refreshToken: readStateValue('cursorAuth/refreshToken') || '',
|
||||
};
|
||||
if (!credential.accessToken && !credential.refreshToken) throw new Error('Cursor credentials are unavailable');
|
||||
const accessToken = await resolveCredentialAccessToken({ ...credential, source: 'import' });
|
||||
if (!accessToken) throw new Error('Cursor credentials are invalid');
|
||||
return writeManagedCredential(providerId, { ...credential, accessToken });
|
||||
};
|
||||
|
||||
const refreshAccessToken = async (auth) => {
|
||||
@@ -165,13 +147,20 @@ const refreshAccessToken = async (auth) => {
|
||||
return body.access_token;
|
||||
};
|
||||
|
||||
const resolveAccessToken = async () => {
|
||||
const auth = loadAuthState();
|
||||
const resolveCredentialAccessToken = async (auth) => {
|
||||
if (!auth.accessToken && !auth.refreshToken) return null;
|
||||
if (!tokenNeedsRefresh(auth.accessToken)) return auth.accessToken;
|
||||
return refreshAccessToken(auth);
|
||||
};
|
||||
|
||||
const resolveAccessToken = async () => resolveCredentialAccessToken(loadAuthState());
|
||||
|
||||
export const validateCursorCredential = async (credential) => {
|
||||
const accessToken = await resolveCredentialAccessToken({ ...credential, source: 'validation' });
|
||||
if (!accessToken) throw new Error('Cursor credentials are invalid');
|
||||
await connectPost(USAGE_URL, accessToken);
|
||||
};
|
||||
|
||||
const connectPost = async (url, accessToken) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { homedir } from 'os';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { buildResult, toUsageWindow, toNumber } from '../utils/index.js';
|
||||
|
||||
const COOKIE_PATH = join(homedir(), '.config', 'ollama-quota', 'cookie');
|
||||
import { readManagedCredential } from '../credentials/providers.js';
|
||||
|
||||
export const providerId = 'ollama-cloud';
|
||||
export const providerName = 'Ollama Cloud';
|
||||
const aliases = ['ollama-cloud', 'ollamacloud'];
|
||||
|
||||
const readCookieFile = () => {
|
||||
try {
|
||||
if (!existsSync(COOKIE_PATH)) return null;
|
||||
const content = readFileSync(COOKIE_PATH, 'utf-8');
|
||||
const trimmed = content.trim();
|
||||
return trimmed || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseOllamaSettingsHtml = (html) => {
|
||||
export const parseOllamaSettingsHtml = (html) => {
|
||||
const windows = {};
|
||||
const sessionMatch = html.match(/Session\s+usage[^0-9]*([0-9.]+)%/i);
|
||||
if (sessionMatch) {
|
||||
@@ -54,14 +39,29 @@ const parseOllamaSettingsHtml = (html) => {
|
||||
};
|
||||
|
||||
export const isConfigured = () => {
|
||||
const cookie = readCookieFile();
|
||||
return Boolean(cookie);
|
||||
return Boolean(readManagedCredential(providerId));
|
||||
};
|
||||
|
||||
export const fetchOllamaCloudUsage = async (credential, fetchImpl = fetch) => {
|
||||
const response = await fetchImpl('https://ollama.com/settings', {
|
||||
method: 'GET',
|
||||
headers: { Cookie: credential.cookie, 'User-Agent': 'OpenChamber quota provider' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) {
|
||||
throw new Error('Ollama Cloud authentication failed');
|
||||
}
|
||||
if (!response.ok) throw new Error(`Ollama Cloud returned HTTP ${response.status}`);
|
||||
const windows = parseOllamaSettingsHtml(await response.text());
|
||||
if (Object.keys(windows).length === 0) throw new Error('Ollama Cloud usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const fetchQuota = async () => {
|
||||
const cookie = readCookieFile();
|
||||
const credential = readManagedCredential(providerId);
|
||||
|
||||
if (!cookie) {
|
||||
if (!credential) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
@@ -72,26 +72,7 @@ export const fetchQuota = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://ollama.com/settings', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: `API error: ${response.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const windows = parseOllamaSettingsHtml(html);
|
||||
const windows = await fetchOllamaCloudUsage(credential);
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
@@ -109,4 +90,4 @@ export const fetchQuota = async () => {
|
||||
error: error instanceof Error ? error.message : 'Request failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { fetchOllamaCloudUsage } from './ollama-cloud.js';
|
||||
|
||||
describe('Ollama Cloud quota provider', () => {
|
||||
it('rejects redirects without forwarding credentials', async () => {
|
||||
await expect(fetchOllamaCloudUsage({ cookie: 'session=secret' }, async () => new Response('', { status: 302 }))).rejects.toThrow('authentication failed');
|
||||
});
|
||||
|
||||
it('rejects successful pages without usage data', async () => {
|
||||
await expect(fetchOllamaCloudUsage({ cookie: 'session=secret' }, async () => new Response('<html></html>'))).rejects.toThrow('could not be parsed');
|
||||
});
|
||||
});
|
||||
@@ -44,9 +44,10 @@ export const fetchOpenCodeGoUsage = async (credential, fetchImpl = fetch) => {
|
||||
Cookie: `auth=${credential.authCookie}`,
|
||||
'User-Agent': 'OpenChamber quota provider',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403 || (response.redirected && /\/auth(?:\/|$|\?)/.test(new URL(response.url).pathname))) {
|
||||
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) {
|
||||
throw new Error('OpenCode Go authentication failed');
|
||||
}
|
||||
if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`);
|
||||
|
||||
Reference in New Issue
Block a user