// Minimax plugin module implements oauth behavior. import { randomBytes, randomUUID } from "node:crypto"; import { MAX_DATE_TIMESTAMP_MS, asSafeIntegerInRange, resolveExpiresAtMsFromDurationOrEpoch, resolvePositiveTimerTimeoutMs, } from "openclaw/plugin-sdk/number-runtime"; import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth"; import { readProviderJsonResponse, readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; export type MiniMaxRegion = "cn" | "global"; const MINIMAX_OAUTH_CONFIG = { cn: { baseUrl: "https://api.minimaxi.com", oauthBaseUrl: "https://account.minimaxi.com", clientId: "78257093-7e40-4613-99e0-527b14b39113", }, global: { baseUrl: "https://api.minimax.io", oauthBaseUrl: "https://account.minimax.io", clientId: "78257093-7e40-4613-99e0-527b14b39113", }, } as const; const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"; const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"; const MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD = 1_000_000_000; const MINIMAX_ABSOLUTE_EXPIRY_MS_THRESHOLD = 1_000_000_000_000; const MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024; const MINIMAX_OAUTH_FETCH_TIMEOUT_MS = 30_000; function getOAuthEndpoints(region: MiniMaxRegion) { const config = MINIMAX_OAUTH_CONFIG[region]; return { codeEndpoint: `${config.oauthBaseUrl}/oauth2/device/code`, tokenEndpoint: `${config.oauthBaseUrl}/oauth2/token`, clientId: config.clientId, baseUrl: config.baseUrl, hostname: new URL(config.oauthBaseUrl).hostname, }; } type MiniMaxOAuthAuthorization = { user_code: string; verification_uri: string; expired_in: number; interval?: number; state: string; }; type MiniMaxOAuthToken = { access: string; refresh: string; expires: number; resourceUrl?: string; notification_message?: string; }; type TokenPending = { status: "pending"; message?: string }; type TokenResult = | { status: "success"; token: MiniMaxOAuthToken } | TokenPending | { status: "error"; message: string }; /** * Normalize MiniMax token endpoint `expired_in` values to the auth-profile * contract: absolute Unix milliseconds. */ export function normalizeOAuthExpires(expiredIn: unknown, now = Date.now()): number | undefined { return resolveExpiresAtMsFromDurationOrEpoch(expiredIn, { nowMs: now, relativeSecondsThreshold: MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD, absoluteMillisecondsThreshold: MINIMAX_ABSOLUTE_EXPIRY_MS_THRESHOLD, }); } function normalizeOAuthAuthorizationExpires(expiredIn: unknown): number | undefined { return asSafeIntegerInRange(expiredIn, { min: 1, max: MAX_DATE_TIMESTAMP_MS }); } function generatePkce(): { verifier: string; challenge: string; state: string } { const { verifier, challenge } = generatePkceVerifierChallenge(); const state = randomBytes(16).toString("base64url"); return { verifier, challenge, state }; } async function requestOAuthCode(params: { challenge: string; state: string; region: MiniMaxRegion; signal?: AbortSignal; }): Promise { const endpoints = getOAuthEndpoints(params.region); const { response, release } = await fetchWithSsrFGuard({ url: endpoints.codeEndpoint, init: { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "x-request-id": randomUUID(), }, body: toFormUrlEncoded({ response_type: "code", client_id: endpoints.clientId, scope: MINIMAX_OAUTH_SCOPE, code_challenge: params.challenge, code_challenge_method: "S256", state: params.state, }), }, ...(params.signal ? { signal: params.signal } : {}), timeoutMs: MINIMAX_OAUTH_FETCH_TIMEOUT_MS, policy: { allowedHostnames: [endpoints.hostname] }, auditContext: "minimax.oauth.code", }); try { if (!response.ok) { const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES); throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`); } const payload = (await readProviderJsonResponse( response, "minimax.oauth-code", )) as MiniMaxOAuthAuthorization & { error?: string }; if (!payload.user_code || !payload.verification_uri) { throw new Error( payload.error ?? "MiniMax OAuth authorization returned an incomplete payload (missing user_code or verification_uri).", ); } if (payload.state !== params.state) { throw new Error("MiniMax OAuth state mismatch: possible CSRF attack or session corruption."); } const expiredIn = normalizeOAuthAuthorizationExpires(payload.expired_in); if (expiredIn === undefined) { throw new Error("MiniMax OAuth authorization returned invalid expired_in."); } return { ...payload, expired_in: expiredIn }; } finally { await release(); } } async function pollOAuthToken(params: { userCode: string; verifier: string; region: MiniMaxRegion; signal?: AbortSignal; }): Promise { const endpoints = getOAuthEndpoints(params.region); const { response, release } = await fetchWithSsrFGuard({ url: endpoints.tokenEndpoint, init: { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: toFormUrlEncoded({ grant_type: MINIMAX_OAUTH_GRANT_TYPE, client_id: endpoints.clientId, user_code: params.userCode, code_verifier: params.verifier, }), }, ...(params.signal ? { signal: params.signal } : {}), timeoutMs: MINIMAX_OAUTH_FETCH_TIMEOUT_MS, policy: { allowedHostnames: [endpoints.hostname] }, auditContext: "minimax.oauth.token", }); try { return await parseMiniMaxOAuthTokenResponse(response); } finally { await release(); } } async function parseMiniMaxOAuthTokenResponse(response: Response): Promise { const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES); let payload: | { status?: string; base_resp?: { status_code?: number; status_msg?: string }; } | undefined; if (text) { try { payload = JSON.parse(text) as typeof payload; } catch { payload = undefined; } } if (!response.ok) { return { status: "error", message: (payload?.base_resp?.status_msg ?? text) || "MiniMax OAuth failed to parse response.", }; } if (!payload) { return { status: "error", message: "MiniMax OAuth failed to parse response." }; } const tokenPayload = payload as { status: string; access_token?: string | null; refresh_token?: string | null; expired_in?: unknown; token_type?: string; resource_url?: string; notification_message?: string; }; if (tokenPayload.status === "error") { return { status: "error", message: "An error occurred. Please try again later" }; } if (tokenPayload.status !== "success") { return { status: "pending", message: "current user code is not authorized" }; } if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expired_in) { return { status: "error", message: "MiniMax OAuth returned incomplete token payload." }; } const expires = normalizeOAuthExpires(tokenPayload.expired_in); if (expires === undefined) { return { status: "error", message: "MiniMax OAuth returned invalid token expiry." }; } return { status: "success", token: { access: tokenPayload.access_token, refresh: tokenPayload.refresh_token, expires, resourceUrl: tokenPayload.resource_url, notification_message: tokenPayload.notification_message, }, }; } export async function loginMiniMaxPortalOAuth(params: { openUrl: (url: string) => Promise; note: (message: string, title?: string) => Promise; progress: { update: (message: string) => void; stop: (message?: string) => void }; region?: MiniMaxRegion; signal?: AbortSignal; }): Promise { // Ensure env-based proxy dispatcher is active before any outbound fetch calls. // Without this, HTTP_PROXY/HTTPS_PROXY env vars are silently ignored (#51619). ensureGlobalUndiciEnvProxyDispatcher(); const region = params.region ?? "global"; const { verifier, challenge, state } = generatePkce(); const oauth = await requestOAuthCode({ challenge, state, region, ...(params.signal ? { signal: params.signal } : {}), }); const verificationUrl = oauth.verification_uri; const noteLines = [ `Open ${verificationUrl} to approve access.`, `If prompted, enter the code ${oauth.user_code}.`, `Interval: ${oauth.interval ?? "default (2000ms)"}, Expires at: ${new Date(oauth.expired_in).toISOString()}`, ]; try { await params.openUrl(verificationUrl); } catch { // Fall back to manual copy/paste if browser open fails. } await params.note(noteLines.join("\n"), "MiniMax OAuth"); let pollIntervalMs = resolvePositiveTimerTimeoutMs(oauth.interval, 2000); // The authorization endpoint returns an absolute millisecond deadline. const expireTimeMs = oauth.expired_in; while (Date.now() < expireTimeMs) { params.progress.update("Waiting for MiniMax OAuth approval…"); const result = await pollOAuthToken({ userCode: oauth.user_code, verifier, region, ...(params.signal ? { signal: params.signal } : {}), }); if (result.status === "success") { return result.token; } if (result.status === "error") { throw new Error(result.message); } const remainingMs = Math.max(0, expireTimeMs - Date.now()); if (remainingMs <= 0) { break; } await waitForMiniMaxOAuthPoll(Math.min(pollIntervalMs, remainingMs), params.signal); pollIntervalMs = Math.max(pollIntervalMs, 2000); } throw new Error("MiniMax OAuth timed out before authorization completed."); } async function waitForMiniMaxOAuthPoll(delayMs: number, signal?: AbortSignal): Promise { if (!signal) { await new Promise((resolve) => { setTimeout(resolve, delayMs); }); return; } if (signal.aborted) { throw signal.reason; } await new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timeout); reject(signal.reason instanceof Error ? signal.reason : new Error("MiniMax login cancelled")); }; const timeout = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, delayMs); signal.addEventListener("abort", onAbort, { once: true }); }); }