Files
smartai/ts/smartai.auth.openai.ts
T

304 lines
10 KiB
TypeScript

import type {
IOpenAiMaxAuthCredentials,
IOpenAiMaxAuthOptions,
IOpenAiMaxCompleteDeviceCodeOptions,
IOpenAiMaxDeviceCode,
IOpenAiMaxDeviceCodePollOptions,
IOpenAiMaxIdTokenInfo,
IOpenAiMaxTokenData,
} 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';
const DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000;
export class OpenAiMaxAuthError extends Error {
public status?: number;
public body?: string;
constructor(message: string, options: { status?: number; body?: string } = {}) {
super(message);
this.name = 'OpenAiMaxAuthError';
this.status = options.status;
this.body = options.body;
}
}
export interface IOpenAiMaxAuthorizationCode {
authorizationCode: string;
codeChallenge: string;
codeVerifier: string;
}
interface IOpenAiMaxTokenResponse {
id_token?: unknown;
access_token?: unknown;
refresh_token?: unknown;
}
function getFetch(options: IOpenAiMaxAuthOptions): typeof fetch {
const fetchFunction = options.fetch ?? globalThis.fetch;
if (!fetchFunction) {
throw new OpenAiMaxAuthError('fetch is not available for OpenAI Max authentication.');
}
return fetchFunction;
}
function getIssuer(options: IOpenAiMaxAuthOptions): string {
return (options.issuer ?? OPENAI_MAX_AUTH_ISSUER).replace(/\/+$/, '');
}
function getClientId(options: IOpenAiMaxAuthOptions): string {
return options.clientId ?? OPENAI_MAX_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}.`);
}
return value;
}
function asOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : 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.');
}
return interval;
}
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}.`, {
status: response.status,
body,
});
}
try {
return body ? JSON.parse(body) : {};
} catch (error) {
throw new OpenAiMaxAuthError(`${context} returned invalid JSON: ${(error as Error).message}`, {
status: response.status,
body,
});
}
}
async function postJson(url: string, body: unknown, options: IOpenAiMaxAuthOptions): Promise<unknown> {
const response = await getFetch(options)(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return readJson(response, `POST ${url}`);
}
async function postForm(url: string, body: URLSearchParams, options: IOpenAiMaxAuthOptions): Promise<unknown> {
const response = await getFetch(options)(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
return readJson(response, `POST ${url}`);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
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.');
}
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}`);
}
}
export function parseOpenAiMaxIdToken(idToken: string): IOpenAiMaxIdTokenInfo {
const claims = parseJwtPayload(idToken);
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;
return {
email: asOptionalString(claims.email) ?? asOptionalString(profile?.email),
chatgptPlanType: asOptionalString(auth?.chatgpt_plan_type),
chatgptUserId: asOptionalString(auth?.chatgpt_user_id) ?? asOptionalString(auth?.user_id),
chatgptAccountId: asOptionalString(auth?.chatgpt_account_id),
chatgptAccountIsFedramp: auth?.chatgpt_account_is_fedramp === true,
expiresAt: expiresAtSeconds ? new Date(expiresAtSeconds * 1000).toISOString() : undefined,
rawJwt: idToken,
};
}
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);
return {
idToken,
accessToken,
refreshToken,
accountId: idTokenInfo.chatgptAccountId,
idTokenInfo,
};
}
export async function requestOpenAiMaxDeviceCode(
options: IOpenAiMaxAuthOptions = {},
): Promise<IOpenAiMaxDeviceCode> {
const issuer = getIssuer(options);
const response = await postJson(`${issuer}/api/accounts/deviceauth/usercode`, {
client_id: getClientId(options),
}, options) as Record<string, unknown>;
return {
verificationUrl: `${issuer}/codex/device`,
userCode: asString(response.user_code ?? response.usercode, 'user_code'),
deviceAuthId: asString(response.device_auth_id, 'device_auth_id'),
intervalSeconds: asIntervalSeconds(response.interval),
};
}
export async function pollOpenAiMaxDeviceCode(
deviceCode: IOpenAiMaxDeviceCode,
options: IOpenAiMaxDeviceCodePollOptions = {},
): Promise<IOpenAiMaxAuthorizationCode> {
const issuer = getIssuer(options);
const pollUrl = `${issuer}/api/accounts/deviceauth/token`;
const timeoutMs = options.timeoutMs ?? DEVICE_CODE_TIMEOUT_MS;
const sleepFunction = options.sleep ?? sleep;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const response = await getFetch(options)(pollUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_auth_id: deviceCode.deviceAuthId,
user_code: deviceCode.userCode,
}),
});
if (response.ok) {
const body = await readJson(response, `POST ${pollUrl}`) as Record<string, unknown>;
return {
authorizationCode: asString(body.authorization_code, 'authorization_code'),
codeChallenge: asString(body.code_challenge, 'code_challenge'),
codeVerifier: asString(body.code_verifier, 'code_verifier'),
};
}
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}.`, {
status: response.status,
body,
});
}
await response.arrayBuffer().catch(() => undefined);
const remaining = timeoutMs - (Date.now() - startedAt);
await sleepFunction(Math.min(deviceCode.intervalSeconds * 1000, Math.max(remaining, 0)));
}
throw new OpenAiMaxAuthError('OpenAI Max device-code login timed out.');
}
export async function exchangeOpenAiMaxAuthorizationCode(
authorizationCode: IOpenAiMaxAuthorizationCode,
options: IOpenAiMaxAuthOptions = {},
): Promise<IOpenAiMaxTokenData> {
const issuer = getIssuer(options);
const response = await postForm(`${issuer}/oauth/token`, new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode.authorizationCode,
redirect_uri: `${issuer}/deviceauth/callback`,
client_id: getClientId(options),
code_verifier: authorizationCode.codeVerifier,
}), options) as IOpenAiMaxTokenResponse;
return createTokenData(response);
}
export function ensureOpenAiMaxWorkspaceAllowed(
tokenData: IOpenAiMaxTokenData,
forcedChatGptWorkspaceId?: string,
): void {
if (!forcedChatGptWorkspaceId) {
return;
}
if (tokenData.idTokenInfo.chatgptAccountId !== forcedChatGptWorkspaceId) {
throw new OpenAiMaxAuthError(`OpenAI Max 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);
return tokenData;
}
export async function refreshOpenAiMaxTokenData(
tokenData: IOpenAiMaxTokenData,
options: IOpenAiMaxAuthOptions = {},
): Promise<IOpenAiMaxTokenData> {
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;
return createTokenData({
id_token: response.id_token ?? tokenData.idToken,
access_token: response.access_token ?? tokenData.accessToken,
refresh_token: response.refresh_token ?? tokenData.refreshToken,
});
}
export function createOpenAiMaxProviderSettings(credentials: IOpenAiMaxAuthCredentials): {
apiKey: string;
baseURL: string;
headers: Record<string, string>;
} {
const accountId = credentials.accountId ?? credentials.idTokenInfo?.chatgptAccountId;
const isFedrampAccount = credentials.idTokenInfo?.chatgptAccountIsFedramp === true;
const headers: Record<string, string> = {
originator: credentials.originator ?? OPENAI_MAX_DEFAULT_ORIGINATOR,
};
if (accountId) {
headers['ChatGPT-Account-ID'] = accountId;
}
if (isFedrampAccount) {
headers['X-OpenAI-Fedramp'] = 'true';
}
return {
apiKey: credentials.accessToken,
baseURL: credentials.baseUrl ?? OPENAI_MAX_CODEX_BASE_URL,
headers,
};
}