mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-15 19:53:29 +00:00
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Pre-commit / pre-commit (push) Has been cancelled
Test / Run Web + Local Brain Smoke (push) Has been cancelled
Test / Run Frontend Guardrails (push) Has been cancelled
Test / Run Python Tests (push) Has been cancelled
Co-authored-by: Douglas <douglas.ym.lai@gmail.com> Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com> Co-authored-by: Tao Sun <168447269+fengju0213@users.noreply.github.com> Co-authored-by: Weijie Bai <happy.regina.bai@gmail.com>
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Reversible fake for Electron safeStorage so the store can be exercised in a
|
|
// plain Node test environment.
|
|
let encryptionAvailable = true;
|
|
vi.mock('electron', () => ({
|
|
safeStorage: {
|
|
isEncryptionAvailable: () => encryptionAvailable,
|
|
encryptString: (s: string) => Buffer.from('enc:' + s, 'utf-8'),
|
|
decryptString: (b: Buffer) => b.toString('utf-8').replace(/^enc:/, ''),
|
|
},
|
|
}));
|
|
|
|
vi.mock('electron-log', () => ({
|
|
default: {
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import {
|
|
DEFAULT_CODEX_OAUTH_AUTH_URL,
|
|
DEFAULT_CODEX_OAUTH_CLIENT_ID,
|
|
DEFAULT_CODEX_OAUTH_REDIRECT_URI,
|
|
DEFAULT_CODEX_OAUTH_SCOPES,
|
|
DEFAULT_CODEX_OAUTH_TOKEN_URL,
|
|
codexOAuthAuthUrl,
|
|
codexOAuthClientId,
|
|
codexOAuthRedirectUri,
|
|
codexOAuthScopes,
|
|
codexOAuthTokenUrl,
|
|
} from '../../../../electron/main/subscriptionAuth/codexOAuth';
|
|
import {
|
|
clearCodexCredential,
|
|
getCodexAccountStatus,
|
|
getCodexAuthFilePath,
|
|
loadCodexCredential,
|
|
saveCodexCredential,
|
|
} from '../../../../electron/main/subscriptionAuth/credentialStore';
|
|
import {
|
|
createPkcePair,
|
|
deriveCodeChallenge,
|
|
generateCodeVerifier,
|
|
} from '../../../../electron/main/subscriptionAuth/pkce';
|
|
import { ensureCodexResolverServer } from '../../../../electron/main/subscriptionAuth/resolverServer';
|
|
import type {
|
|
CodexCredential,
|
|
CodexResolverRuntime,
|
|
} from '../../../../electron/main/subscriptionAuth/types';
|
|
|
|
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
|
|
describe('pkce', () => {
|
|
it('generates a base64url verifier of >= 43 chars', () => {
|
|
const v = generateCodeVerifier();
|
|
expect(v).toMatch(BASE64URL);
|
|
expect(v.length).toBeGreaterThanOrEqual(43);
|
|
});
|
|
|
|
it('derives a stable S256 challenge for a known verifier', () => {
|
|
// RFC 7636 Appendix B test vector.
|
|
const challenge = deriveCodeChallenge(
|
|
'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
|
|
);
|
|
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
|
|
});
|
|
|
|
it('produces unique verifiers and states across calls', () => {
|
|
const a = createPkcePair();
|
|
const b = createPkcePair();
|
|
expect(a.codeChallengeMethod).toBe('S256');
|
|
expect(a.codeVerifier).not.toBe(b.codeVerifier);
|
|
expect(a.state).not.toBe(b.state);
|
|
expect(a.codeChallenge).toBe(deriveCodeChallenge(a.codeVerifier));
|
|
});
|
|
});
|
|
|
|
describe('codexOAuth', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('uses OpenAI Codex OAuth defaults without app configuration', () => {
|
|
vi.stubEnv('CODEX_OAUTH_AUTH_URL', '');
|
|
vi.stubEnv('CODEX_OAUTH_TOKEN_URL', '');
|
|
vi.stubEnv('CODEX_OAUTH_CLIENT_ID', '');
|
|
vi.stubEnv('CODEX_OAUTH_SCOPES', '');
|
|
vi.stubEnv('CODEX_OAUTH_REDIRECT_URI', '');
|
|
|
|
expect(codexOAuthAuthUrl()).toBe(DEFAULT_CODEX_OAUTH_AUTH_URL);
|
|
expect(codexOAuthTokenUrl()).toBe(DEFAULT_CODEX_OAUTH_TOKEN_URL);
|
|
expect(codexOAuthClientId()).toBe(DEFAULT_CODEX_OAUTH_CLIENT_ID);
|
|
expect(codexOAuthScopes()).toBe(DEFAULT_CODEX_OAUTH_SCOPES);
|
|
expect(codexOAuthRedirectUri()).toBe(DEFAULT_CODEX_OAUTH_REDIRECT_URI);
|
|
});
|
|
});
|
|
|
|
describe('credentialStore', () => {
|
|
const email = 'User.Name@example.com';
|
|
let homeDir: string;
|
|
|
|
beforeEach(() => {
|
|
encryptionAvailable = true;
|
|
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-auth-test-'));
|
|
vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const sample: CodexCredential = {
|
|
access_token: 'access-123',
|
|
refresh_token: 'refresh-456',
|
|
token_type: 'Bearer',
|
|
expires_at: '2026-06-24T12:00:00Z',
|
|
account_label: 'user@example.com',
|
|
scopes: ['openid', 'profile'],
|
|
status: 'connected',
|
|
};
|
|
|
|
it('round-trips a credential through encrypted storage', () => {
|
|
saveCodexCredential(email, sample);
|
|
const loaded = loadCodexCredential(email);
|
|
expect(loaded?.access_token).toBe('access-123');
|
|
expect(loaded?.refresh_token).toBe('refresh-456');
|
|
expect(loaded?.account_label).toBe('user@example.com');
|
|
expect(loaded?.status).toBe('connected');
|
|
});
|
|
|
|
it('does not write the raw token to disk in plaintext', () => {
|
|
saveCodexCredential(email, sample);
|
|
const raw = fs.readFileSync(getCodexAuthFilePath(email), 'utf-8');
|
|
expect(raw).not.toContain('access-123');
|
|
expect(raw).not.toContain('refresh-456');
|
|
// Non-sensitive metadata is allowed in plaintext.
|
|
expect(raw).toContain('connected');
|
|
});
|
|
|
|
it('exposes a non-sensitive status view without token material', () => {
|
|
saveCodexCredential(email, sample);
|
|
const view = getCodexAccountStatus(email);
|
|
expect(view).toEqual({
|
|
connected: true,
|
|
status: 'connected',
|
|
account_label: 'user@example.com',
|
|
expires_at: '2026-06-24T12:00:00Z',
|
|
last_error_code: null,
|
|
});
|
|
expect(JSON.stringify(view)).not.toContain('access-123');
|
|
});
|
|
|
|
it('reports not_connected before anything is stored', () => {
|
|
expect(getCodexAccountStatus(email)).toEqual({
|
|
connected: false,
|
|
status: 'not_connected',
|
|
});
|
|
expect(loadCodexCredential(email)).toBeNull();
|
|
});
|
|
|
|
it('clears the credential on disconnect (idempotent)', () => {
|
|
saveCodexCredential(email, sample);
|
|
clearCodexCredential(email);
|
|
expect(loadCodexCredential(email)).toBeNull();
|
|
expect(getCodexAccountStatus(email).connected).toBe(false);
|
|
// second clear must not throw
|
|
expect(() => clearCodexCredential(email)).not.toThrow();
|
|
});
|
|
|
|
it('refuses to persist when OS encryption is unavailable', () => {
|
|
encryptionAvailable = false;
|
|
expect(() => saveCodexCredential(email, sample)).toThrow(/safeStorage/);
|
|
});
|
|
});
|
|
|
|
describe('resolverServer', () => {
|
|
const email = 'resolver@example.com';
|
|
let homeDir: string;
|
|
let resolver: CodexResolverRuntime | null = null;
|
|
|
|
beforeEach(() => {
|
|
encryptionAvailable = true;
|
|
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resolver-test-'));
|
|
vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (resolver) {
|
|
await resolver.close();
|
|
resolver = null;
|
|
}
|
|
vi.unstubAllEnvs();
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('requires the shared secret', async () => {
|
|
resolver = await ensureCodexResolverServer();
|
|
const response = await fetch(`${resolver.url}/codex/token`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
const body = await response.json();
|
|
expect(body).toEqual({ error_code: 'unauthorized' });
|
|
});
|
|
|
|
it('returns a local token only to authorized backend callers', async () => {
|
|
saveCodexCredential(email, {
|
|
access_token: 'resolver-token',
|
|
refresh_token: null,
|
|
token_type: 'Bearer',
|
|
expires_at: null,
|
|
status: 'connected_non_refreshable',
|
|
});
|
|
|
|
resolver = await ensureCodexResolverServer();
|
|
const response = await fetch(`${resolver.url}/codex/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-eigent-resolver-secret': resolver.secret,
|
|
},
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.access_token).toBe('resolver-token');
|
|
expect(body.token_type).toBe('Bearer');
|
|
expect(body.status).toBe('connected_non_refreshable');
|
|
});
|
|
|
|
it('refuses an expired non-refreshable token', async () => {
|
|
saveCodexCredential(email, {
|
|
access_token: 'expired-token',
|
|
refresh_token: null,
|
|
token_type: 'Bearer',
|
|
expires_at: '2000-01-01T00:00:00Z',
|
|
status: 'connected_non_refreshable',
|
|
});
|
|
|
|
resolver = await ensureCodexResolverServer();
|
|
const response = await fetch(`${resolver.url}/codex/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-eigent-resolver-secret': resolver.secret,
|
|
},
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
const body = await response.json();
|
|
expect(body).toMatchObject({
|
|
error_code: 'token_expired',
|
|
status: 'expired',
|
|
});
|
|
expect(JSON.stringify(body)).not.toContain('expired-token');
|
|
});
|
|
|
|
it('refreshes an expired token when a refresh token is available', async () => {
|
|
vi.stubEnv('CODEX_OAUTH_TOKEN_URL', 'https://codex.example/token');
|
|
vi.stubEnv('CODEX_OAUTH_CLIENT_ID', 'codex-client');
|
|
const realFetch = globalThis.fetch.bind(globalThis);
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
|
if (String(input) === 'https://codex.example/token') {
|
|
return Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
access_token: 'fresh-token',
|
|
refresh_token: 'fresh-refresh',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}
|
|
)
|
|
);
|
|
}
|
|
return realFetch(input, init);
|
|
});
|
|
saveCodexCredential(email, {
|
|
access_token: 'expired-token',
|
|
refresh_token: 'old-refresh',
|
|
token_type: 'Bearer',
|
|
expires_at: '2000-01-01T00:00:00Z',
|
|
status: 'connected',
|
|
});
|
|
|
|
resolver = await ensureCodexResolverServer();
|
|
const response = await fetch(`${resolver.url}/codex/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-eigent-resolver-secret': resolver.secret,
|
|
},
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.access_token).toBe('fresh-token');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://codex.example/token',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
const saved = loadCodexCredential(email);
|
|
expect(saved?.access_token).toBe('fresh-token');
|
|
expect(saved?.refresh_token).toBe('fresh-refresh');
|
|
});
|
|
});
|