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:
+85
-76
@@ -1,63 +1,63 @@
|
||||
import type {
|
||||
IOpenAiMaxAuthCredentials,
|
||||
IOpenAiMaxAuthOptions,
|
||||
IOpenAiMaxCompleteDeviceCodeOptions,
|
||||
IOpenAiMaxDeviceCode,
|
||||
IOpenAiMaxDeviceCodePollOptions,
|
||||
IOpenAiMaxIdTokenInfo,
|
||||
IOpenAiMaxTokenData,
|
||||
IOpenAiChatGptAuthCredentials,
|
||||
IOpenAiChatGptAuthOptions,
|
||||
IOpenAiChatGptCompleteDeviceCodeOptions,
|
||||
IOpenAiChatGptDeviceCode,
|
||||
IOpenAiChatGptDeviceCodePollOptions,
|
||||
IOpenAiChatGptTokenData,
|
||||
IOpenAiChatGptTokenInfo,
|
||||
} from './smartai.interfaces.js';
|
||||
|
||||
export const OPENAI_MAX_AUTH_ISSUER = 'https://auth.openai.com';
|
||||
export const OPENAI_MAX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
export const OPENAI_MAX_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex';
|
||||
export const OPENAI_MAX_DEFAULT_ORIGINATOR = 'smartai';
|
||||
export const OPENAI_CHATGPT_AUTH_ISSUER = 'https://auth.openai.com';
|
||||
export const OPENAI_CHATGPT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
export const OPENAI_CHATGPT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex';
|
||||
export const OPENAI_CHATGPT_DEFAULT_ORIGINATOR = 'smartai';
|
||||
|
||||
const DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
export class OpenAiMaxAuthError extends Error {
|
||||
export class OpenAiChatGptAuthError extends Error {
|
||||
public status?: number;
|
||||
public body?: string;
|
||||
|
||||
constructor(message: string, options: { status?: number; body?: string } = {}) {
|
||||
super(message);
|
||||
this.name = 'OpenAiMaxAuthError';
|
||||
this.name = 'OpenAiChatGptAuthError';
|
||||
this.status = options.status;
|
||||
this.body = options.body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IOpenAiMaxAuthorizationCode {
|
||||
export interface IOpenAiChatGptAuthorizationCode {
|
||||
authorizationCode: string;
|
||||
codeChallenge: string;
|
||||
codeVerifier: string;
|
||||
}
|
||||
|
||||
interface IOpenAiMaxTokenResponse {
|
||||
interface IOpenAiChatGptTokenResponse {
|
||||
id_token?: unknown;
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
}
|
||||
|
||||
function getFetch(options: IOpenAiMaxAuthOptions): typeof fetch {
|
||||
function getFetch(options: IOpenAiChatGptAuthOptions): typeof fetch {
|
||||
const fetchFunction = options.fetch ?? globalThis.fetch;
|
||||
if (!fetchFunction) {
|
||||
throw new OpenAiMaxAuthError('fetch is not available for OpenAI Max authentication.');
|
||||
throw new OpenAiChatGptAuthError('fetch is not available for OpenAI ChatGPT authentication.');
|
||||
}
|
||||
return fetchFunction;
|
||||
}
|
||||
|
||||
function getIssuer(options: IOpenAiMaxAuthOptions): string {
|
||||
return (options.issuer ?? OPENAI_MAX_AUTH_ISSUER).replace(/\/+$/, '');
|
||||
function getIssuer(options: IOpenAiChatGptAuthOptions): string {
|
||||
return (options.issuer ?? OPENAI_CHATGPT_AUTH_ISSUER).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getClientId(options: IOpenAiMaxAuthOptions): string {
|
||||
return options.clientId ?? OPENAI_MAX_CLIENT_ID;
|
||||
function getClientId(options: IOpenAiChatGptAuthOptions): string {
|
||||
return options.clientId ?? OPENAI_CHATGPT_CLIENT_ID;
|
||||
}
|
||||
|
||||
function asString(value: unknown, name: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new OpenAiMaxAuthError(`OpenAI Max auth response is missing ${name}.`);
|
||||
throw new OpenAiChatGptAuthError(`OpenAI ChatGPT auth response is missing ${name}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ function asOptionalString(value: unknown): string | undefined {
|
||||
function asIntervalSeconds(value: unknown): number {
|
||||
const interval = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
|
||||
if (!Number.isFinite(interval) || interval <= 0) {
|
||||
throw new OpenAiMaxAuthError('OpenAI Max device-code response has an invalid interval.');
|
||||
throw new OpenAiChatGptAuthError('OpenAI ChatGPT device-code response has an invalid interval.');
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ function asIntervalSeconds(value: unknown): number {
|
||||
async function readJson(response: Response, context: string): Promise<unknown> {
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new OpenAiMaxAuthError(`${context} failed with status ${response.status}.`, {
|
||||
throw new OpenAiChatGptAuthError(`${context} failed with status ${response.status}.`, {
|
||||
status: response.status,
|
||||
body,
|
||||
});
|
||||
@@ -86,14 +86,14 @@ async function readJson(response: Response, context: string): Promise<unknown> {
|
||||
try {
|
||||
return body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
throw new OpenAiMaxAuthError(`${context} returned invalid JSON: ${(error as Error).message}`, {
|
||||
throw new OpenAiChatGptAuthError(`${context} returned invalid JSON: ${(error as Error).message}`, {
|
||||
status: response.status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(url: string, body: unknown, options: IOpenAiMaxAuthOptions): Promise<unknown> {
|
||||
async function postJson(url: string, body: unknown, options: IOpenAiChatGptAuthOptions): Promise<unknown> {
|
||||
const response = await getFetch(options)(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -102,7 +102,7 @@ async function postJson(url: string, body: unknown, options: IOpenAiMaxAuthOptio
|
||||
return readJson(response, `POST ${url}`);
|
||||
}
|
||||
|
||||
async function postForm(url: string, body: URLSearchParams, options: IOpenAiMaxAuthOptions): Promise<unknown> {
|
||||
async function postForm(url: string, body: URLSearchParams, options: IOpenAiChatGptAuthOptions): Promise<unknown> {
|
||||
const response = await getFetch(options)(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
@@ -118,18 +118,18 @@ function sleep(ms: number): Promise<void> {
|
||||
function parseJwtPayload(jwt: string): Record<string, unknown> {
|
||||
const parts = jwt.split('.');
|
||||
if (parts.length !== 3 || !parts[1]) {
|
||||
throw new OpenAiMaxAuthError('OpenAI Max auth returned an invalid ID token.');
|
||||
throw new OpenAiChatGptAuthError('OpenAI ChatGPT auth returned an invalid token.');
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
throw new OpenAiMaxAuthError(`OpenAI Max ID token could not be parsed: ${(error as Error).message}`);
|
||||
throw new OpenAiChatGptAuthError(`OpenAI ChatGPT token could not be parsed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenAiMaxIdToken(idToken: string): IOpenAiMaxIdTokenInfo {
|
||||
const claims = parseJwtPayload(idToken);
|
||||
export function parseOpenAiChatGptTokenInfo(token: string): IOpenAiChatGptTokenInfo {
|
||||
const claims = parseJwtPayload(token);
|
||||
const profile = claims['https://api.openai.com/profile'] as Record<string, unknown> | undefined;
|
||||
const auth = claims['https://api.openai.com/auth'] as Record<string, unknown> | undefined;
|
||||
const expiresAtSeconds = typeof claims.exp === 'number' ? claims.exp : undefined;
|
||||
@@ -141,28 +141,37 @@ export function parseOpenAiMaxIdToken(idToken: string): IOpenAiMaxIdTokenInfo {
|
||||
chatgptAccountId: asOptionalString(auth?.chatgpt_account_id),
|
||||
chatgptAccountIsFedramp: auth?.chatgpt_account_is_fedramp === true,
|
||||
expiresAt: expiresAtSeconds ? new Date(expiresAtSeconds * 1000).toISOString() : undefined,
|
||||
rawJwt: idToken,
|
||||
rawJwt: token,
|
||||
};
|
||||
}
|
||||
|
||||
function createTokenData(response: IOpenAiMaxTokenResponse): IOpenAiMaxTokenData {
|
||||
const idToken = asString(response.id_token, 'id_token');
|
||||
const accessToken = asString(response.access_token, 'access_token');
|
||||
const refreshToken = asString(response.refresh_token, 'refresh_token');
|
||||
const idTokenInfo = parseOpenAiMaxIdToken(idToken);
|
||||
function createTokenData(
|
||||
response: IOpenAiChatGptTokenResponse,
|
||||
existingTokenData?: IOpenAiChatGptTokenData,
|
||||
): IOpenAiChatGptTokenData {
|
||||
const accessToken = asOptionalString(response.access_token) ?? existingTokenData?.accessToken;
|
||||
const refreshToken = asOptionalString(response.refresh_token) ?? existingTokenData?.refreshToken;
|
||||
const idToken = asOptionalString(response.id_token) ?? existingTokenData?.idToken;
|
||||
if (!accessToken) {
|
||||
throw new OpenAiChatGptAuthError('OpenAI ChatGPT auth response is missing access_token.');
|
||||
}
|
||||
if (!refreshToken) {
|
||||
throw new OpenAiChatGptAuthError('OpenAI ChatGPT auth response is missing refresh_token.');
|
||||
}
|
||||
const tokenInfo = parseOpenAiChatGptTokenInfo(idToken ?? accessToken);
|
||||
|
||||
return {
|
||||
idToken,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accountId: idTokenInfo.chatgptAccountId,
|
||||
idTokenInfo,
|
||||
idToken,
|
||||
accountId: tokenInfo.chatgptAccountId,
|
||||
tokenInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestOpenAiMaxDeviceCode(
|
||||
options: IOpenAiMaxAuthOptions = {},
|
||||
): Promise<IOpenAiMaxDeviceCode> {
|
||||
export async function requestOpenAiChatGptDeviceCode(
|
||||
options: IOpenAiChatGptAuthOptions = {},
|
||||
): Promise<IOpenAiChatGptDeviceCode> {
|
||||
const issuer = getIssuer(options);
|
||||
const response = await postJson(`${issuer}/api/accounts/deviceauth/usercode`, {
|
||||
client_id: getClientId(options),
|
||||
@@ -176,10 +185,10 @@ export async function requestOpenAiMaxDeviceCode(
|
||||
};
|
||||
}
|
||||
|
||||
export async function pollOpenAiMaxDeviceCode(
|
||||
deviceCode: IOpenAiMaxDeviceCode,
|
||||
options: IOpenAiMaxDeviceCodePollOptions = {},
|
||||
): Promise<IOpenAiMaxAuthorizationCode> {
|
||||
export async function pollOpenAiChatGptDeviceCode(
|
||||
deviceCode: IOpenAiChatGptDeviceCode,
|
||||
options: IOpenAiChatGptDeviceCodePollOptions = {},
|
||||
): Promise<IOpenAiChatGptAuthorizationCode> {
|
||||
const issuer = getIssuer(options);
|
||||
const pollUrl = `${issuer}/api/accounts/deviceauth/token`;
|
||||
const timeoutMs = options.timeoutMs ?? DEVICE_CODE_TIMEOUT_MS;
|
||||
@@ -207,7 +216,7 @@ export async function pollOpenAiMaxDeviceCode(
|
||||
|
||||
if (response.status !== 403 && response.status !== 404) {
|
||||
const body = await response.text();
|
||||
throw new OpenAiMaxAuthError(`OpenAI Max device-code polling failed with status ${response.status}.`, {
|
||||
throw new OpenAiChatGptAuthError(`OpenAI ChatGPT device-code polling failed with status ${response.status}.`, {
|
||||
status: response.status,
|
||||
body,
|
||||
});
|
||||
@@ -218,13 +227,13 @@ export async function pollOpenAiMaxDeviceCode(
|
||||
await sleepFunction(Math.min(deviceCode.intervalSeconds * 1000, Math.max(remaining, 0)));
|
||||
}
|
||||
|
||||
throw new OpenAiMaxAuthError('OpenAI Max device-code login timed out.');
|
||||
throw new OpenAiChatGptAuthError('OpenAI ChatGPT device-code login timed out.');
|
||||
}
|
||||
|
||||
export async function exchangeOpenAiMaxAuthorizationCode(
|
||||
authorizationCode: IOpenAiMaxAuthorizationCode,
|
||||
options: IOpenAiMaxAuthOptions = {},
|
||||
): Promise<IOpenAiMaxTokenData> {
|
||||
export async function exchangeOpenAiChatGptAuthorizationCode(
|
||||
authorizationCode: IOpenAiChatGptAuthorizationCode,
|
||||
options: IOpenAiChatGptAuthOptions = {},
|
||||
): Promise<IOpenAiChatGptTokenData> {
|
||||
const issuer = getIssuer(options);
|
||||
const response = await postForm(`${issuer}/oauth/token`, new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
@@ -232,60 +241,60 @@ export async function exchangeOpenAiMaxAuthorizationCode(
|
||||
redirect_uri: `${issuer}/deviceauth/callback`,
|
||||
client_id: getClientId(options),
|
||||
code_verifier: authorizationCode.codeVerifier,
|
||||
}), options) as IOpenAiMaxTokenResponse;
|
||||
}), options) as IOpenAiChatGptTokenResponse;
|
||||
|
||||
return createTokenData(response);
|
||||
}
|
||||
|
||||
export function ensureOpenAiMaxWorkspaceAllowed(
|
||||
tokenData: IOpenAiMaxTokenData,
|
||||
export function ensureOpenAiChatGptWorkspaceAllowed(
|
||||
tokenData: IOpenAiChatGptTokenData,
|
||||
forcedChatGptWorkspaceId?: string,
|
||||
): void {
|
||||
if (!forcedChatGptWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
if (tokenData.idTokenInfo.chatgptAccountId !== forcedChatGptWorkspaceId) {
|
||||
throw new OpenAiMaxAuthError(`OpenAI Max login is restricted to workspace ${forcedChatGptWorkspaceId}.`);
|
||||
if (tokenData.tokenInfo.chatgptAccountId !== forcedChatGptWorkspaceId) {
|
||||
throw new OpenAiChatGptAuthError(`OpenAI ChatGPT login is restricted to workspace ${forcedChatGptWorkspaceId}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeOpenAiMaxDeviceCodeLogin(
|
||||
deviceCode: IOpenAiMaxDeviceCode,
|
||||
options: IOpenAiMaxCompleteDeviceCodeOptions = {},
|
||||
): Promise<IOpenAiMaxTokenData> {
|
||||
const authorizationCode = await pollOpenAiMaxDeviceCode(deviceCode, options);
|
||||
const tokenData = await exchangeOpenAiMaxAuthorizationCode(authorizationCode, options);
|
||||
ensureOpenAiMaxWorkspaceAllowed(tokenData, options.forcedChatGptWorkspaceId);
|
||||
export async function completeOpenAiChatGptDeviceCodeLogin(
|
||||
deviceCode: IOpenAiChatGptDeviceCode,
|
||||
options: IOpenAiChatGptCompleteDeviceCodeOptions = {},
|
||||
): Promise<IOpenAiChatGptTokenData> {
|
||||
const authorizationCode = await pollOpenAiChatGptDeviceCode(deviceCode, options);
|
||||
const tokenData = await exchangeOpenAiChatGptAuthorizationCode(authorizationCode, options);
|
||||
ensureOpenAiChatGptWorkspaceAllowed(tokenData, options.forcedChatGptWorkspaceId);
|
||||
return tokenData;
|
||||
}
|
||||
|
||||
export async function refreshOpenAiMaxTokenData(
|
||||
tokenData: IOpenAiMaxTokenData,
|
||||
options: IOpenAiMaxAuthOptions = {},
|
||||
): Promise<IOpenAiMaxTokenData> {
|
||||
export async function refreshOpenAiChatGptTokenData(
|
||||
tokenData: IOpenAiChatGptTokenData,
|
||||
options: IOpenAiChatGptAuthOptions = {},
|
||||
): Promise<IOpenAiChatGptTokenData> {
|
||||
const issuer = getIssuer(options);
|
||||
const response = await postJson(`${issuer}/oauth/token`, {
|
||||
client_id: getClientId(options),
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokenData.refreshToken,
|
||||
}, options) as IOpenAiMaxTokenResponse;
|
||||
}, options) as IOpenAiChatGptTokenResponse;
|
||||
|
||||
return createTokenData({
|
||||
id_token: response.id_token ?? tokenData.idToken,
|
||||
access_token: response.access_token ?? tokenData.accessToken,
|
||||
refresh_token: response.refresh_token ?? tokenData.refreshToken,
|
||||
});
|
||||
}, tokenData);
|
||||
}
|
||||
|
||||
export function createOpenAiMaxProviderSettings(credentials: IOpenAiMaxAuthCredentials): {
|
||||
export function createOpenAiChatGptProviderSettings(credentials: IOpenAiChatGptAuthCredentials): {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
headers: Record<string, string>;
|
||||
} {
|
||||
const accountId = credentials.accountId ?? credentials.idTokenInfo?.chatgptAccountId;
|
||||
const isFedrampAccount = credentials.idTokenInfo?.chatgptAccountIsFedramp === true;
|
||||
const accountId = credentials.accountId ?? credentials.tokenInfo?.chatgptAccountId;
|
||||
const isFedrampAccount = credentials.tokenInfo?.chatgptAccountIsFedramp === true;
|
||||
const headers: Record<string, string> = {
|
||||
originator: credentials.originator ?? OPENAI_MAX_DEFAULT_ORIGINATOR,
|
||||
originator: credentials.originator ?? OPENAI_CHATGPT_DEFAULT_ORIGINATOR,
|
||||
};
|
||||
|
||||
if (accountId) {
|
||||
@@ -297,7 +306,7 @@ export function createOpenAiMaxProviderSettings(credentials: IOpenAiMaxAuthCrede
|
||||
|
||||
return {
|
||||
apiKey: credentials.accessToken,
|
||||
baseURL: credentials.baseUrl ?? OPENAI_MAX_CODEX_BASE_URL,
|
||||
baseURL: credentials.baseUrl ?? OPENAI_CHATGPT_CODEX_BASE_URL,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user