288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
function loadModule() {
|
|
const converterSource = fs.readFileSync('shared/session-to-json-converter.js', 'utf8');
|
|
const localModuleSource = fs.readFileSync('background/local-cli-proxy-api.js', 'utf8');
|
|
return new Function(
|
|
'self',
|
|
`${converterSource}\n${localModuleSource}; return self.MultiPageBackgroundLocalCliProxyApi;`
|
|
)({});
|
|
}
|
|
|
|
function encodeBase64UrlJson(value) {
|
|
return Buffer.from(JSON.stringify(value), 'utf8')
|
|
.toString('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/g, '');
|
|
}
|
|
|
|
function createJwt(payload) {
|
|
return `${encodeBase64UrlJson({ alg: 'none', typ: 'JWT' })}.${encodeBase64UrlJson(payload)}.signature`;
|
|
}
|
|
|
|
test('local cli proxy api module exposes factory', () => {
|
|
const backgroundSource = fs.readFileSync('background.js', 'utf8');
|
|
assert.match(backgroundSource, /background\/local-cli-proxy-api\.js/);
|
|
const source = fs.readFileSync('background/local-cli-proxy-api.js', 'utf8');
|
|
const api = new Function('self', `${source}; return self.MultiPageBackgroundLocalCliProxyApi;`)({});
|
|
assert.equal(typeof api?.createLocalCliProxyApi, 'function');
|
|
});
|
|
|
|
test('local cli proxy api generates codex OAuth URL with CLIProxyAPI-compatible params', async () => {
|
|
const api = loadModule();
|
|
const client = api.createLocalCliProxyApi({
|
|
crypto: globalThis.crypto,
|
|
fetch: async () => {
|
|
throw new Error('fetch should not be called');
|
|
},
|
|
});
|
|
|
|
const result = await client.createAuthorizationRequest({
|
|
state: 'abc123state',
|
|
});
|
|
|
|
const parsed = new URL(result.oauthUrl);
|
|
assert.equal(parsed.origin + parsed.pathname, 'https://auth.openai.com/oauth/authorize');
|
|
assert.equal(parsed.searchParams.get('client_id'), 'app_EMoamEEZ73f0CkXaXp7hrann');
|
|
assert.equal(parsed.searchParams.get('response_type'), 'code');
|
|
assert.equal(parsed.searchParams.get('redirect_uri'), 'http://localhost:1455/auth/callback');
|
|
assert.equal(parsed.searchParams.get('scope'), 'openid email profile offline_access');
|
|
assert.equal(parsed.searchParams.get('state'), 'abc123state');
|
|
assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256');
|
|
assert.equal(parsed.searchParams.get('prompt'), 'login');
|
|
assert.equal(parsed.searchParams.get('id_token_add_organizations'), 'true');
|
|
assert.equal(parsed.searchParams.get('codex_cli_simplified_flow'), 'true');
|
|
assert.ok(result.pkceCodes.codeVerifier.length >= 43);
|
|
assert.ok(result.pkceCodes.codeChallenge.length >= 43);
|
|
});
|
|
|
|
test('local cli proxy api exchanges code for tokens using OpenAI OAuth token endpoint', async () => {
|
|
const api = loadModule();
|
|
const calls = [];
|
|
const client = api.createLocalCliProxyApi({
|
|
crypto: globalThis.crypto,
|
|
fetch: async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify({
|
|
access_token: 'access-token-value',
|
|
refresh_token: 'refresh-token-value',
|
|
id_token: 'id-token-value',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
}),
|
|
};
|
|
},
|
|
});
|
|
|
|
const result = await client.exchangeCodeForTokens({
|
|
code: 'oauth-code',
|
|
pkceCodes: {
|
|
codeVerifier: 'verifier-123',
|
|
},
|
|
});
|
|
|
|
assert.equal(calls.length, 1);
|
|
const [{ url, options }] = calls;
|
|
assert.equal(url, 'https://auth.openai.com/oauth/token');
|
|
assert.equal(options.method, 'POST');
|
|
assert.equal(options.headers['Content-Type'], 'application/x-www-form-urlencoded');
|
|
assert.equal(options.headers.Accept, 'application/json');
|
|
const body = new URLSearchParams(options.body);
|
|
assert.equal(body.get('grant_type'), 'authorization_code');
|
|
assert.equal(body.get('client_id'), 'app_EMoamEEZ73f0CkXaXp7hrann');
|
|
assert.equal(body.get('code'), 'oauth-code');
|
|
assert.equal(body.get('redirect_uri'), 'http://localhost:1455/auth/callback');
|
|
assert.equal(body.get('code_verifier'), 'verifier-123');
|
|
assert.equal(result.accessToken, 'access-token-value');
|
|
assert.equal(result.refreshToken, 'refresh-token-value');
|
|
assert.equal(result.idToken, 'id-token-value');
|
|
assert.equal(result.tokenType, 'Bearer');
|
|
assert.equal(result.expiresIn, 3600);
|
|
});
|
|
|
|
test('local cli proxy api turns exchanged tokens into local codex auth json and writes into plugin directory', async () => {
|
|
const api = loadModule();
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'flowpilot-local-cpa-'));
|
|
const pluginDir = path.join(tmpRoot, 'plugin');
|
|
const accessToken = createJwt({
|
|
exp: Math.trunc(Date.now() / 1000) + 3600,
|
|
email: 'local@example.com',
|
|
'https://api.openai.com/auth': {
|
|
chatgpt_account_id: 'acct-local-123',
|
|
chatgpt_plan_type: 'plus',
|
|
chatgpt_user_id: 'user-local-1',
|
|
},
|
|
});
|
|
const idToken = createJwt({
|
|
email: 'local@example.com',
|
|
'https://api.openai.com/auth': {
|
|
chatgpt_account_id: 'acct-local-123',
|
|
chatgpt_plan_type: 'plus',
|
|
chatgpt_user_id: 'user-local-1',
|
|
},
|
|
});
|
|
|
|
const client = api.createLocalCliProxyApi({
|
|
crypto: globalThis.crypto,
|
|
fetch: async () => {
|
|
throw new Error('fetch should not be called');
|
|
},
|
|
ensureDirectory: async (dirPath) => {
|
|
await fs.promises.mkdir(dirPath, { recursive: true });
|
|
},
|
|
writeTextFile: async (filePath, text) => {
|
|
await fs.promises.writeFile(filePath, text, 'utf8');
|
|
},
|
|
});
|
|
|
|
const artifact = await client.buildAuthJsonArtifact({
|
|
pluginDir,
|
|
relativeAuthDir: '.cli-proxy-api',
|
|
accessToken,
|
|
refreshToken: 'refresh-local-123',
|
|
idToken,
|
|
sourceName: 'CLIProxyAPI Local OAuth',
|
|
now: new Date('2026-05-20T00:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(artifact.fileName, 'codex-local@example.com-plus.json');
|
|
assert.equal(artifact.authJson.type, 'codex');
|
|
assert.equal(artifact.authJson.email, 'local@example.com');
|
|
assert.equal(artifact.authJson.account_id, 'acct-local-123');
|
|
assert.equal(artifact.authJson.plan_type, 'plus');
|
|
assert.equal(artifact.authJson.refresh_token, 'refresh-local-123');
|
|
assert.deepEqual(artifact.warnings, []);
|
|
|
|
const saved = await client.saveAuthJsonArtifact(artifact);
|
|
assert.equal(saved.saved, true);
|
|
assert.equal(fs.existsSync(saved.filePath), true);
|
|
|
|
const savedJson = JSON.parse(fs.readFileSync(saved.filePath, 'utf8'));
|
|
assert.equal(savedJson.email, 'local@example.com');
|
|
assert.equal(savedJson.plan_type, 'plus');
|
|
assert.equal(savedJson.type, 'codex');
|
|
});
|
|
|
|
test('local cli proxy api uses CLIProxyAPI-style team filename hashing', async () => {
|
|
const api = loadModule();
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'flowpilot-local-cpa-team-'));
|
|
const pluginDir = path.join(tmpRoot, 'plugin');
|
|
const accessToken = createJwt({
|
|
exp: Math.trunc(Date.now() / 1000) + 3600,
|
|
email: 'team@example.com',
|
|
'https://api.openai.com/auth': {
|
|
chatgpt_account_id: 'acct-team-xyz',
|
|
chatgpt_plan_type: 'team',
|
|
},
|
|
});
|
|
const idToken = createJwt({
|
|
email: 'team@example.com',
|
|
'https://api.openai.com/auth': {
|
|
chatgpt_account_id: 'acct-team-xyz',
|
|
chatgpt_plan_type: 'team',
|
|
},
|
|
});
|
|
|
|
const client = api.createLocalCliProxyApi({
|
|
crypto: globalThis.crypto,
|
|
fetch: async () => {
|
|
throw new Error('fetch should not be called');
|
|
},
|
|
});
|
|
|
|
const artifact = await client.buildAuthJsonArtifact({
|
|
pluginDir,
|
|
accessToken,
|
|
refreshToken: 'refresh-team',
|
|
idToken,
|
|
});
|
|
|
|
assert.match(artifact.fileName, /^codex-[0-9a-f]{8}-team@example\.com-team\.json$/);
|
|
});
|
|
|
|
test('local cli proxy api converts ChatGPT session directly without refresh token', async () => {
|
|
const api = loadModule();
|
|
const client = api.createLocalCliProxyApi({
|
|
crypto: globalThis.crypto,
|
|
fetch: async () => {
|
|
throw new Error('fetch should not be called');
|
|
},
|
|
});
|
|
|
|
const artifact = await client.buildAuthJsonArtifact({
|
|
pluginDir: 'C:\\plugin',
|
|
relativeAuthDir: '.cli-proxy-api',
|
|
session: {
|
|
accessToken: 'session-access-token',
|
|
sessionToken: 'session-cookie-token',
|
|
expires: '2026-05-20T12:00:00.000Z',
|
|
user: {
|
|
id: 'user-session-1',
|
|
email: 'session@example.com',
|
|
},
|
|
account: {
|
|
id: 'acct-session-1',
|
|
planType: 'free',
|
|
},
|
|
},
|
|
accessToken: 'session-access-token',
|
|
lastRefresh: '',
|
|
sourceName: 'SessionToJson Local No RT',
|
|
});
|
|
|
|
assert.equal(artifact.fileName, 'codex-session@example.com-free.json');
|
|
assert.equal(artifact.authJson.email, 'session@example.com');
|
|
assert.equal(artifact.authJson.account_id, 'acct-session-1');
|
|
assert.equal(artifact.authJson.plan_type, 'free');
|
|
assert.equal(artifact.authJson.session_token, 'session-cookie-token');
|
|
assert.equal(artifact.authJson.refresh_token, '');
|
|
assert.equal(artifact.authJson.last_refresh, '');
|
|
assert.match(artifact.authJson.id_token, /^[^.]+\.[^.]+\.$/);
|
|
assert.ok(artifact.warnings.some((warning) => /Missing refresh_token/.test(warning)));
|
|
});
|
|
|
|
test('local cli proxy api rejects mismatched OAuth callback state', () => {
|
|
const api = loadModule();
|
|
assert.throws(
|
|
() => api.parseOAuthCallback('http://localhost:1455/auth/callback?code=ok&state=wrong-state', 'expected-state'),
|
|
/OAuth state 不匹配/
|
|
);
|
|
});
|
|
|
|
test('local cli proxy api blocks relative auth dir traversal', async () => {
|
|
const api = loadModule();
|
|
const client = api.createLocalCliProxyApi({
|
|
crypto: globalThis.crypto,
|
|
fetch: async () => {
|
|
throw new Error('fetch should not be called');
|
|
},
|
|
});
|
|
const accessToken = createJwt({
|
|
exp: Math.trunc(Date.now() / 1000) + 3600,
|
|
email: 'safe@example.com',
|
|
'https://api.openai.com/auth': {
|
|
chatgpt_account_id: 'acct-safe',
|
|
chatgpt_plan_type: 'plus',
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => client.buildAuthJsonArtifact({
|
|
pluginDir: 'C:\\plugin',
|
|
relativeAuthDir: '../escape',
|
|
accessToken,
|
|
refreshToken: 'refresh-safe',
|
|
idToken: accessToken,
|
|
}),
|
|
/relativeAuthDir 不能包含/
|
|
);
|
|
});
|