feat(openai-chatgpt-auth)!: rename ChatGPT auth APIs
Add Node-only auth source helpers for SmartAI, OpenCode, and Codex credentials.
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user