/** * Device OAuth Manager * * Manages Device Code OAuth flows for MiniMax and Qwen providers. * * The OAuth protocol implementations are fully self-contained in: * - ./minimax-oauth.ts (MiniMax Device Code + PKCE) * - ./qwen-oauth.ts (Qwen Device Code + PKCE) * * This approach: * - Hardcodes client_id and endpoints (same as openai-codex-oauth.ts) * - Implements OAuth flows locally with zero openclaw dependency * - Survives openclaw package upgrades without breakage * - Works identically on macOS, Windows, and Linux * * We provide our own callbacks (openUrl/note/progress) that hook into * the Electron IPC system to display UI in the ClawX frontend. */ import { EventEmitter } from 'events'; import { BrowserWindow, shell } from 'electron'; import { logger } from './logger'; import { saveProvider, getProvider, ProviderConfig } from './secure-storage'; import { getProviderDefaultModel } from './provider-registry'; import { proxyAwareFetch } from './proxy-fetch'; import { saveOAuthTokenToOpenClaw, setOpenClawDefaultModelWithOverride } from './openclaw-auth'; import { loginMiniMaxPortalOAuth, type MiniMaxOAuthToken, type MiniMaxRegion } from './minimax-oauth'; import { loginQwenPortalOAuth, type QwenOAuthToken } from './qwen-oauth'; export type OAuthProviderType = 'minimax-portal' | 'minimax-portal-cn' | 'qwen-portal'; // Re-export types for consumers export type { MiniMaxRegion, MiniMaxOAuthToken, QwenOAuthToken }; // ───────────────────────────────────────────────────────────── // DeviceOAuthManager // ───────────────────────────────────────────────────────────── class DeviceOAuthManager extends EventEmitter { private activeProvider: OAuthProviderType | null = null; private activeAccountId: string | null = null; private activeLabel: string | null = null; private active: boolean = false; private mainWindow: BrowserWindow | null = null; private async runWithProxyAwareFetch(task: () => Promise): Promise { const originalFetch = globalThis.fetch; globalThis.fetch = ((input: string | URL, init?: RequestInit) => proxyAwareFetch(input, init)) as typeof fetch; try { return await task(); } finally { globalThis.fetch = originalFetch; } } setWindow(window: BrowserWindow) { this.mainWindow = window; } async startFlow( provider: OAuthProviderType, region: MiniMaxRegion = 'global', options?: { accountId?: string; label?: string }, ): Promise { if (this.active) { await this.stopFlow(); } this.active = true; this.emit('oauth:start', { provider, accountId: options?.accountId || provider }); this.activeProvider = provider; this.activeAccountId = options?.accountId || provider; this.activeLabel = options?.label || null; try { if (provider === 'minimax-portal' || provider === 'minimax-portal-cn') { const actualRegion = provider === 'minimax-portal-cn' ? 'cn' : (region || 'global'); await this.runMiniMaxFlow(actualRegion, provider); } else if (provider === 'qwen-portal') { await this.runQwenFlow(); } else { throw new Error(`Unsupported OAuth provider type: ${provider}`); } return true; } catch (error) { if (!this.active) { // Flow was cancelled — not an error return false; } logger.error(`[DeviceOAuth] Flow error for ${provider}:`, error); this.emitError(error instanceof Error ? error.message : String(error)); this.active = false; this.activeProvider = null; this.activeAccountId = null; this.activeLabel = null; return false; } } async stopFlow(): Promise { this.active = false; this.activeProvider = null; this.activeAccountId = null; this.activeLabel = null; logger.info('[DeviceOAuth] Flow explicitly stopped'); } // ───────────────────────────────────────────────────────── // MiniMax flow // ───────────────────────────────────────────────────────── private async runMiniMaxFlow(region?: MiniMaxRegion, providerType: OAuthProviderType = 'minimax-portal'): Promise { const provider = this.activeProvider!; const token: MiniMaxOAuthToken = await this.runWithProxyAwareFetch(() => loginMiniMaxPortalOAuth({ region, openUrl: async (url: string) => { logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`); // Open the authorization URL in the system browser shell.openExternal(url).catch((err: unknown) => logger.warn(`[DeviceOAuth] Failed to open browser:`, err) ); }, note: async (message: string, _title?: string) => { if (!this.active) return; // The extension calls note() with a message containing // the user_code and verification_uri — parse them for the UI const { verificationUri, userCode } = this.parseNote(message); if (verificationUri && userCode) { this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 }); } else { logger.info(`[DeviceOAuth] MiniMax note: ${message}`); } }, progress: { update: (msg: string) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`), stop: (msg?: string) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`), }, })); if (!this.active) return; await this.onSuccess(providerType, { access: token.access, refresh: token.refresh, expires: token.expires, // MiniMax returns a per-account resourceUrl as the API base URL resourceUrl: token.resourceUrl, // Revert back to anthropic-messages api: 'anthropic-messages', region, }); } // ───────────────────────────────────────────────────────── // Qwen flow // ───────────────────────────────────────────────────────── private async runQwenFlow(): Promise { const provider = this.activeProvider!; const token: QwenOAuthToken = await this.runWithProxyAwareFetch(() => loginQwenPortalOAuth({ openUrl: async (url: string) => { logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`); shell.openExternal(url).catch((err: unknown) => logger.warn(`[DeviceOAuth] Failed to open browser:`, err) ); }, note: async (message: string, _title?: string) => { if (!this.active) return; const { verificationUri, userCode } = this.parseNote(message); if (verificationUri && userCode) { this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 }); } else { logger.info(`[DeviceOAuth] Qwen note: ${message}`); } }, progress: { update: (msg: string) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`), stop: (msg?: string) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`), }, })); if (!this.active) return; await this.onSuccess('qwen-portal', { access: token.access, refresh: token.refresh, expires: token.expires, // Qwen returns a per-account resourceUrl as the API base URL resourceUrl: token.resourceUrl, // Qwen uses OpenAI Completions API format api: 'openai-completions', }); } // ───────────────────────────────────────────────────────── // Success handler // ───────────────────────────────────────────────────────── private async onSuccess(providerType: OAuthProviderType, token: { access: string; refresh: string; expires: number; resourceUrl?: string; api: 'anthropic-messages' | 'openai-completions'; region?: MiniMaxRegion; }) { const accountId = this.activeAccountId || providerType; const accountLabel = this.activeLabel; this.active = false; this.activeProvider = null; this.activeAccountId = null; this.activeLabel = null; logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`); // 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format. // (matches what `openclaw models auth login` → upsertAuthProfile writes). // We save both MiniMax providers to the generic "minimax-portal" profile // so OpenClaw's gateway auto-refresher knows how to find it. try { const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType; await saveOAuthTokenToOpenClaw(tokenProviderId, { access: token.access, refresh: token.refresh, expires: token.expires, }); } catch (err) { logger.warn(`[DeviceOAuth] Failed to save OAuth token to OpenClaw:`, err); } // 2. Write openclaw.json: set default model + provider config (baseUrl/api/models) // This mirrors what the OpenClaw plugin's configPatch does after CLI login. // The baseUrl comes from token.resourceUrl (per-account URL from the OAuth server) // or falls back to the provider's default public endpoint. const defaultBaseUrl = providerType === 'minimax-portal' ? 'https://api.minimax.io/anthropic' : (providerType === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); let baseUrl = token.resourceUrl || defaultBaseUrl; // Ensure baseUrl has a protocol prefix if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { baseUrl = 'https://' + baseUrl; } // Ensure the base URL ends with /anthropic if (providerType.startsWith('minimax-portal') && baseUrl) { baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; } else if (providerType === 'qwen-portal' && baseUrl) { // Ensure Qwen API gets /v1 at the end if (!baseUrl.endsWith('/v1')) { baseUrl = baseUrl.replace(/\/$/, '') + '/v1'; } } try { const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType; await setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, { baseUrl, api: token.api, // Tells OpenClaw's anthropic adapter to use `Authorization: Bearer` instead of `x-api-key` authHeader: providerType.startsWith('minimax-portal') ? true : undefined, // OAuth placeholder — tells Gateway to resolve credentials // from auth-profiles.json (type: 'oauth') instead of a static API key. apiKeyEnv: tokenProviderId === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', }); } catch (err) { logger.warn(`[DeviceOAuth] Failed to configure openclaw models:`, err); } // 3. Save provider record in ClawX's own store so UI shows it as configured const existing = await getProvider(accountId); const nameMap: Record = { 'minimax-portal': 'MiniMax (Global)', 'minimax-portal-cn': 'MiniMax (CN)', 'qwen-portal': 'Qwen', }; const providerConfig: ProviderConfig = { id: accountId, name: accountLabel || nameMap[providerType as OAuthProviderType] || providerType, type: providerType, enabled: existing?.enabled ?? true, baseUrl, // Save the dynamically resolved URL (Global vs CN) model: existing?.model || getProviderDefaultModel(providerType), createdAt: existing?.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; await saveProvider(providerConfig); // 4. Emit success internally so the main process can restart the Gateway this.emit('oauth:success', { provider: providerType, accountId }); // 5. Emit success to frontend if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send('oauth:success', { provider: providerType, accountId, success: true }); } } // ───────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────── /** * Parse user_code and verification_uri from the note message sent by * the OpenClaw extension's loginXxxPortalOAuth function. * * Note format (minimax-portal-auth/oauth.ts): * "Open https://platform.minimax.io/oauth-authorize?user_code=dyMj_wOhpK&client=... to approve access.\n" * "If prompted, enter the code dyMj_wOhpK.\n" * ... * * user_code format: mixed-case alphanumeric with underscore, e.g. "dyMj_wOhpK" */ private parseNote(message: string): { verificationUri?: string; userCode?: string } { // Primary: extract URL (everything between "Open " and " to") const urlMatch = message.match(/Open\s+(https?:\/\/\S+?)\s+to/i); const verificationUri = urlMatch?.[1]; let userCode: string | undefined; // Method 1: extract user_code from URL query param (most reliable) if (verificationUri) { try { const parsed = new URL(verificationUri); const qp = parsed.searchParams.get('user_code'); if (qp) userCode = qp; } catch { // fall through to text-based extraction } } // Method 2: text-based extraction — matches mixed-case alnum + underscore/hyphen codes if (!userCode) { const codeMatch = message.match(/enter.*?code\s+([A-Za-z0-9][A-Za-z0-9_-]{3,})/i); if (codeMatch?.[1]) userCode = codeMatch[1].replace(/\.$/, ''); // strip trailing period } return { verificationUri, userCode }; } private emitCode(data: { provider: string; verificationUri: string; userCode: string; expiresIn: number; }) { this.emit('oauth:code', data); if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send('oauth:code', data); } } private emitError(message: string) { this.emit('oauth:error', { message }); if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send('oauth:error', { message }); } } } export const deviceOAuthManager = new DeviceOAuthManager();