diff --git a/src/auth/index.ts b/src/auth/index.ts index f6d792e..b42331c 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -20,12 +20,17 @@ export { loadStoredOpenAIAuth, storeOpenAIAuth, clearOpenAIAuth, + loadStoredOpenAIApiKey, + storeOpenAIApiKey, + clearOpenAIApiKey, + getOpenAIApiKey, refreshOpenAIAuth, ensureValidOpenAIAuth, loginOpenAI, parseJwtClaims, extractAccountId, type OpenAIOAuthInfo, + type OpenAIApiKeyInfo, type IdTokenClaims, } from './openai.js'; diff --git a/src/auth/openai.test.ts b/src/auth/openai.test.ts index 7cdd940..ce6dbce 100644 --- a/src/auth/openai.test.ts +++ b/src/auth/openai.test.ts @@ -1,6 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { mkdtempSync, statSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { parseJwtClaims, extractAccountId } from './openai.js'; +import { extractAccountId, parseJwtClaims } from './openai.js'; function base64UrlEncode(obj: unknown): string { return Buffer.from(JSON.stringify(obj)).toString('base64url'); @@ -41,3 +44,64 @@ describe('OpenAI OAuth helpers', () => { expect(extractAccountId(tokens)).toBe('org_1'); }); }); + +describe('auth/openai api key storage', () => { + const originalHome = process.env.HOME; + const originalEnvKey = process.env.OPENAI_API_KEY; + + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'flynn-auth-openai-')); + process.env.HOME = homeDir; + delete process.env.OPENAI_API_KEY; + vi.resetModules(); + }); + + afterEach(() => { + process.env.HOME = originalHome; + if (originalEnvKey) { + process.env.OPENAI_API_KEY = originalEnvKey; + } else { + delete process.env.OPENAI_API_KEY; + } + }); + + it('stores, loads, and clears OpenAI API key (preserves OAuth entry)', async () => { + const mod = await import('./openai.js'); + + expect(mod.loadStoredOpenAIApiKey()).toBeNull(); + expect(mod.loadStoredOpenAIAuth()).toBeNull(); + + mod.storeOpenAIApiKey('sk-test'); + expect(mod.loadStoredOpenAIApiKey()).toBe('sk-test'); + + const authFile = join(homeDir, '.config/flynn/auth.json'); + const mode = statSync(authFile).mode & 0o777; + expect(mode).toBe(0o600); + + const oauth = { + access_token: 'at', + refresh_token: 'rt', + expires_at: Date.now() + 60_000, + created_at: new Date().toISOString(), + }; + mod.storeOpenAIAuth(oauth); + + expect(mod.loadStoredOpenAIAuth()?.access_token).toBe('at'); + expect(mod.loadStoredOpenAIApiKey()).toBe('sk-test'); + + mod.clearOpenAIAuth(); + expect(mod.loadStoredOpenAIAuth()).toBeNull(); + expect(mod.loadStoredOpenAIApiKey()).toBe('sk-test'); + + mod.clearOpenAIApiKey(); + expect(mod.loadStoredOpenAIApiKey()).toBeNull(); + }); + + it('getOpenAIApiKey prefers environment variable', async () => { + process.env.OPENAI_API_KEY = 'sk-env'; + const mod = await import('./openai.js'); + expect(mod.getOpenAIApiKey()).toBe('sk-env'); + }); +}); diff --git a/src/auth/openai.ts b/src/auth/openai.ts index 4792358..f403d2c 100644 --- a/src/auth/openai.ts +++ b/src/auth/openai.ts @@ -25,10 +25,71 @@ export interface OpenAIOAuthInfo { created_at: string; } +export interface OpenAIApiKeyInfo { + api_key: string; + created_at: string; +} + +interface OpenAIStoreEntry { + oauth?: OpenAIOAuthInfo; + api_key?: OpenAIApiKeyInfo; +} + interface AuthStore { // Leave github entry untyped here so this module does not depend on github.ts. github?: unknown; - openai?: OpenAIOAuthInfo; + /** OpenAI credentials. Backward compatible with legacy OAuth-only entries. */ + openai?: OpenAIStoreEntry | OpenAIOAuthInfo; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isOpenAIOAuthInfo(value: unknown): value is OpenAIOAuthInfo { + if (!isRecord(value)) { + return false; + } + return typeof value.access_token === 'string' + && typeof value.refresh_token === 'string' + && typeof value.expires_at === 'number' + && typeof value.created_at === 'string'; +} + +function isOpenAIApiKeyInfo(value: unknown): value is OpenAIApiKeyInfo { + if (!isRecord(value)) { + return false; + } + return typeof value.api_key === 'string' + && typeof value.created_at === 'string'; +} + +function readOpenAIEntry(store: AuthStore): OpenAIStoreEntry | null { + const raw = store.openai as unknown; + if (!raw) { + return null; + } + + // Legacy format: auth.json.openai stored the OAuth info directly. + if (isOpenAIOAuthInfo(raw)) { + return { oauth: raw }; + } + + if (!isRecord(raw)) { + return null; + } + + const oauth = isOpenAIOAuthInfo(raw.oauth) ? raw.oauth : undefined; + const apiKey = isOpenAIApiKeyInfo(raw.api_key) ? raw.api_key : undefined; + return { oauth, api_key: apiKey }; +} + +function writeOpenAIEntry(store: AuthStore, entry: OpenAIStoreEntry | null): void { + if (!entry || (!entry.oauth && !entry.api_key)) { + delete store.openai; + return; + } + store.openai = entry; } interface DeviceAuthResponse { @@ -83,21 +144,69 @@ function writeAuthStore(store: AuthStore): void { export function loadStoredOpenAIAuth(): OpenAIOAuthInfo | null { const store = readAuthStore(); - return store.openai ?? null; + const entry = readOpenAIEntry(store); + return entry?.oauth ?? null; } export function storeOpenAIAuth(info: OpenAIOAuthInfo): void { const store = readAuthStore(); - store.openai = info; + const entry = readOpenAIEntry(store) ?? {}; + entry.oauth = info; + writeOpenAIEntry(store, entry); writeAuthStore(store); } export function clearOpenAIAuth(): void { const store = readAuthStore(); - delete store.openai; + const entry = readOpenAIEntry(store); + if (entry) { + delete entry.oauth; + writeOpenAIEntry(store, entry); + } else { + delete store.openai; + } writeAuthStore(store); } +export function loadStoredOpenAIApiKey(): string | null { + const store = readAuthStore(); + const entry = readOpenAIEntry(store); + return entry?.api_key?.api_key ?? null; +} + +export function storeOpenAIApiKey(key: string): void { + const trimmed = key.trim(); + if (!trimmed) { + throw new Error('OpenAI API key is empty'); + } + const store = readAuthStore(); + const entry = readOpenAIEntry(store) ?? {}; + entry.api_key = { api_key: trimmed, created_at: new Date().toISOString() }; + writeOpenAIEntry(store, entry); + writeAuthStore(store); +} + +export function clearOpenAIApiKey(): void { + const store = readAuthStore(); + const entry = readOpenAIEntry(store); + if (!entry) { + return; + } + delete entry.api_key; + writeOpenAIEntry(store, entry); + writeAuthStore(store); +} + +/** + * Get an OpenAI API key from any available source. + * Priority: OPENAI_API_KEY → stored auth.json. + */ +export function getOpenAIApiKey(): string | null { + return process.env.OPENAI_API_KEY + ?? loadStoredOpenAIApiKey() + ?? null; +} + export function parseJwtClaims(token: string): IdTokenClaims | undefined { const parts = token.split('.'); if (parts.length !== 3) {return undefined;}