auth: add OpenAI API key storage
This commit is contained in:
@@ -20,12 +20,17 @@ export {
|
|||||||
loadStoredOpenAIAuth,
|
loadStoredOpenAIAuth,
|
||||||
storeOpenAIAuth,
|
storeOpenAIAuth,
|
||||||
clearOpenAIAuth,
|
clearOpenAIAuth,
|
||||||
|
loadStoredOpenAIApiKey,
|
||||||
|
storeOpenAIApiKey,
|
||||||
|
clearOpenAIApiKey,
|
||||||
|
getOpenAIApiKey,
|
||||||
refreshOpenAIAuth,
|
refreshOpenAIAuth,
|
||||||
ensureValidOpenAIAuth,
|
ensureValidOpenAIAuth,
|
||||||
loginOpenAI,
|
loginOpenAI,
|
||||||
parseJwtClaims,
|
parseJwtClaims,
|
||||||
extractAccountId,
|
extractAccountId,
|
||||||
type OpenAIOAuthInfo,
|
type OpenAIOAuthInfo,
|
||||||
|
type OpenAIApiKeyInfo,
|
||||||
type IdTokenClaims,
|
type IdTokenClaims,
|
||||||
} from './openai.js';
|
} from './openai.js';
|
||||||
|
|
||||||
|
|||||||
+66
-2
@@ -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 {
|
function base64UrlEncode(obj: unknown): string {
|
||||||
return Buffer.from(JSON.stringify(obj)).toString('base64url');
|
return Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||||
@@ -41,3 +44,64 @@ describe('OpenAI OAuth helpers', () => {
|
|||||||
expect(extractAccountId(tokens)).toBe('org_1');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+112
-3
@@ -25,10 +25,71 @@ export interface OpenAIOAuthInfo {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenAIApiKeyInfo {
|
||||||
|
api_key: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenAIStoreEntry {
|
||||||
|
oauth?: OpenAIOAuthInfo;
|
||||||
|
api_key?: OpenAIApiKeyInfo;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthStore {
|
interface AuthStore {
|
||||||
// Leave github entry untyped here so this module does not depend on github.ts.
|
// Leave github entry untyped here so this module does not depend on github.ts.
|
||||||
github?: unknown;
|
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 {
|
interface DeviceAuthResponse {
|
||||||
@@ -83,21 +144,69 @@ function writeAuthStore(store: AuthStore): void {
|
|||||||
|
|
||||||
export function loadStoredOpenAIAuth(): OpenAIOAuthInfo | null {
|
export function loadStoredOpenAIAuth(): OpenAIOAuthInfo | null {
|
||||||
const store = readAuthStore();
|
const store = readAuthStore();
|
||||||
return store.openai ?? null;
|
const entry = readOpenAIEntry(store);
|
||||||
|
return entry?.oauth ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storeOpenAIAuth(info: OpenAIOAuthInfo): void {
|
export function storeOpenAIAuth(info: OpenAIOAuthInfo): void {
|
||||||
const store = readAuthStore();
|
const store = readAuthStore();
|
||||||
store.openai = info;
|
const entry = readOpenAIEntry(store) ?? {};
|
||||||
|
entry.oauth = info;
|
||||||
|
writeOpenAIEntry(store, entry);
|
||||||
writeAuthStore(store);
|
writeAuthStore(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearOpenAIAuth(): void {
|
export function clearOpenAIAuth(): void {
|
||||||
const store = readAuthStore();
|
const store = readAuthStore();
|
||||||
|
const entry = readOpenAIEntry(store);
|
||||||
|
if (entry) {
|
||||||
|
delete entry.oauth;
|
||||||
|
writeOpenAIEntry(store, entry);
|
||||||
|
} else {
|
||||||
delete store.openai;
|
delete store.openai;
|
||||||
|
}
|
||||||
writeAuthStore(store);
|
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 {
|
export function parseJwtClaims(token: string): IdTokenClaims | undefined {
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
if (parts.length !== 3) {return undefined;}
|
if (parts.length !== 3) {return undefined;}
|
||||||
|
|||||||
Reference in New Issue
Block a user