auth: add OpenAI API key storage

This commit is contained in:
William Valentin
2026-02-15 10:26:19 -08:00
parent 9755487793
commit bcf6c377d5
3 changed files with 184 additions and 6 deletions
+5
View File
@@ -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';
+66 -2
View File
@@ -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');
});
});
+113 -4
View File
@@ -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<string, unknown> {
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;}