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:
2026-05-14 16:44:15 +00:00
parent c3664ba57f
commit 10587998f2
10 changed files with 631 additions and 144 deletions
+351
View File
@@ -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;
};