10587998f2
Add Node-only auth source helpers for SmartAI, OpenCode, and Codex credentials.
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
import * as fs from 'node:fs/promises';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import {
|
|
parseOpenAiChatGptTokenInfo,
|
|
refreshOpenAiChatGptTokenData,
|
|
} from '../ts/smartai.auth.openai.js';
|
|
import type {
|
|
IOpenAiChatGptAuthOptions,
|
|
IOpenAiChatGptTokenData,
|
|
IOpenAiChatGptTokenInfo,
|
|
} from '../ts/smartai.interfaces.js';
|
|
|
|
export type TOpenAiChatGptAuthSource = 'smartai' | 'opencode' | 'codex';
|
|
export type TOpenAiChatGptAuthFileFormat = TOpenAiChatGptAuthSource | 'auto';
|
|
|
|
export interface IOpenAiChatGptAuthSourceConfig {
|
|
source: TOpenAiChatGptAuthSource;
|
|
filePath?: string;
|
|
format?: TOpenAiChatGptAuthFileFormat;
|
|
writeBack?: boolean;
|
|
}
|
|
|
|
export interface IOpenAiChatGptAuthSourceInspection {
|
|
source: TOpenAiChatGptAuthSource;
|
|
filePath: string;
|
|
exists: boolean;
|
|
usable: boolean;
|
|
expired?: boolean;
|
|
accountId?: string;
|
|
email?: string;
|
|
plan?: string;
|
|
expiresAt?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export interface IInspectOpenAiChatGptAuthSourcesOptions {
|
|
sources?: Array<TOpenAiChatGptAuthSource | IOpenAiChatGptAuthSourceConfig>;
|
|
homeDir?: string;
|
|
now?: Date;
|
|
}
|
|
|
|
export interface IResolveOpenAiChatGptAuthOptions extends IInspectOpenAiChatGptAuthSourcesOptions {
|
|
refresh?: 'ifNeeded' | false;
|
|
writeBack?: Partial<Record<TOpenAiChatGptAuthSource, boolean>>;
|
|
authOptions?: IOpenAiChatGptAuthOptions;
|
|
}
|
|
|
|
export interface IResolvedOpenAiChatGptAuth {
|
|
source: TOpenAiChatGptAuthSource;
|
|
filePath: string;
|
|
tokenData: IOpenAiChatGptTokenData;
|
|
refreshed: boolean;
|
|
}
|
|
|
|
interface INormalizedOpenAiChatGptAuthSourceConfig {
|
|
source: TOpenAiChatGptAuthSource;
|
|
filePath: string;
|
|
format: TOpenAiChatGptAuthFileFormat;
|
|
writeBack?: boolean;
|
|
}
|
|
|
|
const defaultSources: TOpenAiChatGptAuthSource[] = ['smartai', 'opencode', 'codex'];
|
|
const refreshWindowMs = 5 * 60 * 1000;
|
|
|
|
export const getDefaultOpenAiChatGptAuthPath = (
|
|
source: TOpenAiChatGptAuthSource,
|
|
homeDir = os.homedir(),
|
|
): string => {
|
|
switch (source) {
|
|
case 'smartai':
|
|
return path.join(homeDir, '.git.zone', 'ide', 'openai-chatgpt-auth.json');
|
|
case 'opencode':
|
|
return path.join(homeDir, '.local', 'share', 'opencode', 'auth.json');
|
|
case 'codex':
|
|
return path.join(homeDir, '.codex', 'auth.json');
|
|
default:
|
|
throw new Error(`Unsupported OpenAI ChatGPT auth source: ${source satisfies never}`);
|
|
}
|
|
};
|
|
|
|
export const normalizeOpenAiChatGptAuth = (
|
|
input: unknown,
|
|
format: TOpenAiChatGptAuthFileFormat = 'auto',
|
|
): IOpenAiChatGptTokenData | undefined => {
|
|
if (format === 'auto') {
|
|
return normalizeOpenAiChatGptAuth(input, 'smartai')
|
|
?? normalizeOpenAiChatGptAuth(input, 'opencode')
|
|
?? normalizeOpenAiChatGptAuth(input, 'codex');
|
|
}
|
|
|
|
if (!isRecord(input)) return undefined;
|
|
if (format === 'opencode') {
|
|
return normalizeOpenCodeAuth(input);
|
|
}
|
|
return normalizeTokenObject(isRecord(input.tokens) ? input.tokens : input);
|
|
};
|
|
|
|
export const readOpenAiChatGptAuthFile = async (
|
|
filePath: string,
|
|
format: TOpenAiChatGptAuthFileFormat = 'auto',
|
|
): Promise<IOpenAiChatGptTokenData | undefined> => {
|
|
const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown;
|
|
return normalizeOpenAiChatGptAuth(parsed, format);
|
|
};
|
|
|
|
export const inspectOpenAiChatGptAuthSources = async (
|
|
options: IInspectOpenAiChatGptAuthSourcesOptions = {},
|
|
): Promise<IOpenAiChatGptAuthSourceInspection[]> => {
|
|
const now = options.now ?? new Date();
|
|
const sourceConfigs = normalizeSourceConfigs(options.sources, options.homeDir);
|
|
return Promise.all(sourceConfigs.map(async (sourceConfig) => {
|
|
try {
|
|
const tokenData = await readOpenAiChatGptAuthFile(sourceConfig.filePath!, sourceConfig.format ?? sourceConfig.source);
|
|
if (!tokenData) {
|
|
return {
|
|
source: sourceConfig.source,
|
|
filePath: sourceConfig.filePath!,
|
|
exists: true,
|
|
usable: false,
|
|
error: 'No OpenAI ChatGPT auth token found in file.',
|
|
};
|
|
}
|
|
return toInspection(sourceConfig.source, sourceConfig.filePath!, tokenData, now);
|
|
} catch (error) {
|
|
const nodeError = error as NodeJS.ErrnoException;
|
|
return {
|
|
source: sourceConfig.source,
|
|
filePath: sourceConfig.filePath!,
|
|
exists: nodeError.code !== 'ENOENT',
|
|
usable: false,
|
|
error: nodeError.code === 'ENOENT' ? undefined : nodeError.message,
|
|
};
|
|
}
|
|
}));
|
|
};
|
|
|
|
export const resolveOpenAiChatGptAuth = async (
|
|
options: IResolveOpenAiChatGptAuthOptions = {},
|
|
): Promise<IResolvedOpenAiChatGptAuth | undefined> => {
|
|
const now = options.now ?? new Date();
|
|
const sourceConfigs = normalizeSourceConfigs(options.sources, options.homeDir);
|
|
const refresh = options.refresh ?? 'ifNeeded';
|
|
|
|
for (const sourceConfig of sourceConfigs) {
|
|
let tokenData: IOpenAiChatGptTokenData | undefined;
|
|
try {
|
|
tokenData = await readOpenAiChatGptAuthFile(sourceConfig.filePath!, sourceConfig.format ?? sourceConfig.source);
|
|
} catch (error) {
|
|
const nodeError = error as NodeJS.ErrnoException;
|
|
if (nodeError.code === 'ENOENT') continue;
|
|
continue;
|
|
}
|
|
if (!tokenData) continue;
|
|
|
|
const shouldRefresh = refresh === 'ifNeeded' && shouldRefreshToken(tokenData, now);
|
|
const writeBack = sourceConfig.writeBack ?? options.writeBack?.[sourceConfig.source] ?? sourceConfig.source === 'smartai';
|
|
if (!shouldRefresh) {
|
|
return { source: sourceConfig.source, filePath: sourceConfig.filePath!, tokenData, refreshed: false };
|
|
}
|
|
|
|
if (!writeBack) {
|
|
if (!isExpired(tokenData, now)) {
|
|
return { source: sourceConfig.source, filePath: sourceConfig.filePath!, tokenData, refreshed: false };
|
|
}
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const refreshed = await refreshOpenAiChatGptTokenData(tokenData, options.authOptions ?? {});
|
|
await writeOpenAiChatGptAuthFile(sourceConfig.filePath!, refreshed, sourceConfig.format ?? sourceConfig.source);
|
|
return { source: sourceConfig.source, filePath: sourceConfig.filePath!, tokenData: refreshed, refreshed: true };
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
export const writeOpenAiChatGptAuthFile = async (
|
|
filePath: string,
|
|
tokenData: IOpenAiChatGptTokenData,
|
|
format: TOpenAiChatGptAuthFileFormat = 'smartai',
|
|
): Promise<void> => {
|
|
const current = await readJsonFileIfExists(filePath);
|
|
const payload = format === 'opencode'
|
|
? toOpenCodeAuthFile(current, tokenData)
|
|
: format === 'codex'
|
|
? toCodexAuthFile(current, tokenData)
|
|
: toSmartAiAuthFile(tokenData);
|
|
await writeJsonAtomic(filePath, payload);
|
|
};
|
|
|
|
const normalizeSourceConfigs = (
|
|
sources: Array<TOpenAiChatGptAuthSource | IOpenAiChatGptAuthSourceConfig> | undefined,
|
|
homeDir = os.homedir(),
|
|
): INormalizedOpenAiChatGptAuthSourceConfig[] => {
|
|
return (sources ?? defaultSources).map((sourceInput) => {
|
|
const sourceConfig = typeof sourceInput === 'string' ? { source: sourceInput } : sourceInput;
|
|
return {
|
|
source: sourceConfig.source,
|
|
filePath: sourceConfig.filePath ?? getDefaultOpenAiChatGptAuthPath(sourceConfig.source, homeDir),
|
|
format: sourceConfig.format ?? sourceConfig.source,
|
|
writeBack: sourceConfig.writeBack,
|
|
};
|
|
});
|
|
};
|
|
|
|
const toInspection = (
|
|
source: TOpenAiChatGptAuthSource,
|
|
filePath: string,
|
|
tokenData: IOpenAiChatGptTokenData,
|
|
now: Date,
|
|
): IOpenAiChatGptAuthSourceInspection => {
|
|
const expired = isExpired(tokenData, now);
|
|
return {
|
|
source,
|
|
filePath,
|
|
exists: true,
|
|
usable: !expired,
|
|
expired,
|
|
accountId: tokenData.accountId ?? tokenData.tokenInfo.chatgptAccountId,
|
|
email: tokenData.tokenInfo.email,
|
|
plan: tokenData.tokenInfo.chatgptPlanType,
|
|
expiresAt: tokenData.tokenInfo.expiresAt,
|
|
};
|
|
};
|
|
|
|
const normalizeTokenObject = (input: Record<string, unknown>): IOpenAiChatGptTokenData | undefined => {
|
|
const accessToken = stringValue(input.accessToken) ?? stringValue(input.access_token);
|
|
const refreshToken = stringValue(input.refreshToken) ?? stringValue(input.refresh_token);
|
|
const idToken = stringValue(input.idToken) ?? stringValue(input.id_token) ?? stringValue((input.tokenInfo as Record<string, unknown> | undefined)?.rawJwt);
|
|
const accountId = stringValue(input.accountId) ?? stringValue(input.account_id);
|
|
return createTokenDataFromValues({ accessToken, refreshToken, idToken, accountId });
|
|
};
|
|
|
|
const normalizeOpenCodeAuth = (input: Record<string, unknown>): IOpenAiChatGptTokenData | undefined => {
|
|
if (!isRecord(input.openai)) return undefined;
|
|
const accessToken = stringValue(input.openai.access);
|
|
const refreshToken = stringValue(input.openai.refresh);
|
|
const accountId = stringValue(input.openai.accountId);
|
|
const expiresAt = typeof input.openai.expires === 'number' ? new Date(input.openai.expires).toISOString() : undefined;
|
|
return createTokenDataFromValues({ accessToken, refreshToken, accountId, fallbackExpiresAt: expiresAt });
|
|
};
|
|
|
|
const createTokenDataFromValues = (input: {
|
|
accessToken?: string;
|
|
refreshToken?: string;
|
|
idToken?: string;
|
|
accountId?: string;
|
|
fallbackExpiresAt?: string;
|
|
}): IOpenAiChatGptTokenData | undefined => {
|
|
if (!input.accessToken || !input.refreshToken) return undefined;
|
|
const parseToken = input.idToken ?? input.accessToken;
|
|
const tokenInfo = parseTokenInfo(parseToken, input.accountId, input.fallbackExpiresAt);
|
|
return {
|
|
accessToken: input.accessToken,
|
|
refreshToken: input.refreshToken,
|
|
idToken: input.idToken,
|
|
accountId: input.accountId ?? tokenInfo.chatgptAccountId,
|
|
tokenInfo,
|
|
};
|
|
};
|
|
|
|
const parseTokenInfo = (token: string, accountId?: string, fallbackExpiresAt?: string): IOpenAiChatGptTokenInfo => {
|
|
try {
|
|
const tokenInfo = parseOpenAiChatGptTokenInfo(token);
|
|
return {
|
|
...tokenInfo,
|
|
chatgptAccountId: tokenInfo.chatgptAccountId ?? accountId,
|
|
expiresAt: tokenInfo.expiresAt ?? fallbackExpiresAt,
|
|
};
|
|
} catch {
|
|
return {
|
|
chatgptAccountId: accountId,
|
|
chatgptAccountIsFedramp: false,
|
|
expiresAt: fallbackExpiresAt,
|
|
rawJwt: token,
|
|
};
|
|
}
|
|
};
|
|
|
|
const toSmartAiAuthFile = (tokenData: IOpenAiChatGptTokenData): Record<string, unknown> => ({
|
|
accessToken: tokenData.accessToken,
|
|
refreshToken: tokenData.refreshToken,
|
|
idToken: tokenData.idToken,
|
|
accountId: tokenData.accountId,
|
|
tokenInfo: tokenData.tokenInfo,
|
|
});
|
|
|
|
const toCodexAuthFile = (current: Record<string, unknown>, tokenData: IOpenAiChatGptTokenData): Record<string, unknown> => ({
|
|
...current,
|
|
OPENAI_API_KEY: current.OPENAI_API_KEY ?? null,
|
|
tokens: {
|
|
...(isRecord(current.tokens) ? current.tokens : {}),
|
|
id_token: tokenData.idToken,
|
|
access_token: tokenData.accessToken,
|
|
refresh_token: tokenData.refreshToken,
|
|
account_id: tokenData.accountId ?? tokenData.tokenInfo.chatgptAccountId,
|
|
},
|
|
last_refresh: new Date().toISOString(),
|
|
});
|
|
|
|
const toOpenCodeAuthFile = (current: Record<string, unknown>, tokenData: IOpenAiChatGptTokenData): Record<string, unknown> => ({
|
|
...current,
|
|
openai: {
|
|
...(isRecord(current.openai) ? current.openai : {}),
|
|
type: 'oauth',
|
|
refresh: tokenData.refreshToken,
|
|
access: tokenData.accessToken,
|
|
expires: tokenData.tokenInfo.expiresAt ? Date.parse(tokenData.tokenInfo.expiresAt) : undefined,
|
|
accountId: tokenData.accountId ?? tokenData.tokenInfo.chatgptAccountId,
|
|
},
|
|
});
|
|
|
|
const shouldRefreshToken = (tokenData: IOpenAiChatGptTokenData, now: Date): boolean => {
|
|
if (!tokenData.tokenInfo.expiresAt) return false;
|
|
return Date.parse(tokenData.tokenInfo.expiresAt) - now.getTime() < refreshWindowMs;
|
|
};
|
|
|
|
const isExpired = (tokenData: IOpenAiChatGptTokenData, now: Date): boolean => {
|
|
if (!tokenData.tokenInfo.expiresAt) return false;
|
|
return Date.parse(tokenData.tokenInfo.expiresAt) <= now.getTime();
|
|
};
|
|
|
|
const readJsonFileIfExists = async (filePath: string): Promise<Record<string, unknown>> => {
|
|
try {
|
|
const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown;
|
|
return isRecord(parsed) ? parsed : {};
|
|
} catch (error) {
|
|
const nodeError = error as NodeJS.ErrnoException;
|
|
if (nodeError.code === 'ENOENT') return {};
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const writeJsonAtomic = async (filePath: string, payload: unknown): Promise<void> => {
|
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
await fs.writeFile(tempPath, `${JSON.stringify(payload, undefined, 2)}\n`, { mode: 0o600 });
|
|
await fs.rename(tempPath, filePath);
|
|
};
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
};
|
|
|
|
const stringValue = (value: unknown): string | undefined => {
|
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
};
|