/** * Self-contained MiniMax Device Code OAuth flow. * * Implements RFC 8628 (Device Authorization Grant) with PKCE for MiniMax API. * Zero dependency on openclaw extension modules — survives openclaw upgrades. * * Protocol: * 1. POST /oauth/code → get user_code, verification_uri * 2. Open verification_uri in browser * 3. Poll POST /oauth/token with user_code until approved * 4. Return { access, refresh, expires, resourceUrl } */ import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { proxyAwareFetch } from './proxy-fetch'; // ── Constants ──────────────────────────────────────────────── export type MiniMaxRegion = 'cn' | 'global'; const MINIMAX_OAUTH_CONFIG = { cn: { baseUrl: 'https://api.minimaxi.com', clientId: '78257093-7e40-4613-99e0-527b14b39113', }, global: { baseUrl: 'https://api.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'; function getOAuthEndpoints(region: MiniMaxRegion) { const config = MINIMAX_OAUTH_CONFIG[region]; return { codeEndpoint: `${config.baseUrl}/oauth/code`, tokenEndpoint: `${config.baseUrl}/oauth/token`, clientId: config.clientId, baseUrl: config.baseUrl, }; } // ── Types ──────────────────────────────────────────────────── export interface MiniMaxOAuthToken { access: string; refresh: string; expires: number; resourceUrl?: string; notification_message?: string; } interface MiniMaxOAuthAuthorization { user_code: string; verification_uri: string; expired_in: number; interval?: number; state: string; } type TokenResult = | { status: 'success'; token: MiniMaxOAuthToken } | { status: 'pending'; message?: string } | { status: 'error'; message: string }; export interface MiniMaxOAuthOptions { openUrl: (url: string) => Promise; note: (message: string, title?: string) => Promise; progress: { update: (message: string) => void; stop: (message?: string) => void }; region?: MiniMaxRegion; } // ── PKCE helpers (self-contained, no openclaw dependency) ──── function generatePkce(): { verifier: string; challenge: string; state: string } { const verifier = randomBytes(32).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); const state = randomBytes(16).toString('base64url'); return { verifier, challenge, state }; } function toFormUrlEncoded(params: Record): string { return new URLSearchParams(params).toString(); } // ── OAuth flow steps ───────────────────────────────────────── async function requestOAuthCode(params: { challenge: string; state: string; region: MiniMaxRegion; }): Promise { const endpoints = getOAuthEndpoints(params.region); const response = await proxyAwareFetch(endpoints.codeEndpoint, { 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, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`); } const payload = (await response.json()) 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.'); } return payload; } async function pollOAuthToken(params: { userCode: string; verifier: string; region: MiniMaxRegion; }): Promise { const endpoints = getOAuthEndpoints(params.region); const response = await proxyAwareFetch(endpoints.tokenEndpoint, { 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, }), }); const text = await response.text(); 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?: number | null; 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.' }; } return { status: 'success', token: { access: tokenPayload.access_token, refresh: tokenPayload.refresh_token, expires: tokenPayload.expired_in, resourceUrl: tokenPayload.resource_url, notification_message: tokenPayload.notification_message, }, }; } // ── Public API ─────────────────────────────────────────────── export async function loginMiniMaxPortalOAuth(params: MiniMaxOAuthOptions): Promise { const region = params.region ?? 'global'; const { verifier, challenge, state } = generatePkce(); const oauth = await requestOAuthCode({ challenge, state, region }); 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: ${oauth.expired_in} unix timestamp`, ]; await params.note(noteLines.join('\n'), 'MiniMax OAuth'); try { await params.openUrl(verificationUrl); } catch { // Fall back to manual copy/paste if browser open fails. } let pollIntervalMs = oauth.interval ? oauth.interval : 2000; 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, }); if (result.status === 'success') { return result.token; } if (result.status === 'error') { throw new Error(result.message); } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); pollIntervalMs = Math.max(pollIntervalMs, 2000); } throw new Error('MiniMax OAuth timed out before authorization completed.'); }