|
1 | | -import type { OAuthTokens } from './types'; |
| 1 | +import type { OAuthCredentials, Region } from '../config/schema'; |
| 2 | +import { OAUTH_HOSTS } from '../config/schema'; |
2 | 3 | import { CLIError } from '../errors/base'; |
3 | 4 | import { ExitCode } from '../errors/codes'; |
4 | 5 |
|
5 | | -// OAuth configuration — exact endpoints TBD pending MiniMax OAuth docs |
6 | | -export interface OAuthConfig { |
7 | | - clientId: string; |
8 | | - authorizationUrl: string; |
9 | | - tokenUrl: string; |
10 | | - deviceCodeUrl: string; |
11 | | - scopes: string[]; |
12 | | - callbackPort: number; |
| 6 | +const CLIENT_ID = '659cf4c1-615c-45f6-a5f6-4bf15eb476e5'; |
| 7 | +const CLIENT_NAME = 'MiniMax CLI'; |
| 8 | +const SCOPES = ['openid', 'profile', 'coding_plan']; |
| 9 | + |
| 10 | +interface DeviceCodeResponse { |
| 11 | + base_resp?: { status_code: number; status_msg?: string }; |
| 12 | + user_code: string; |
| 13 | + verification_uri: string; |
| 14 | + expired_in: number; // absolute Unix ms (server's name; unfortunate) |
| 15 | + interval: number; // poll interval in ms |
| 16 | + state: string; |
13 | 17 | } |
14 | 18 |
|
15 | | -const DEFAULT_OAUTH_CONFIG: OAuthConfig = { |
16 | | - clientId: 'mmx-cli', |
17 | | - authorizationUrl: 'https://platform.minimax.io/oauth/authorize', |
18 | | - tokenUrl: 'https://api.minimax.io/v1/oauth/token', |
19 | | - deviceCodeUrl: 'https://api.minimax.io/v1/oauth/device/code', |
20 | | - scopes: ['api'], |
21 | | - callbackPort: 18991, |
22 | | -}; |
23 | | - |
24 | | -export async function startBrowserFlow( |
25 | | - config: OAuthConfig = DEFAULT_OAUTH_CONFIG, |
26 | | -): Promise<OAuthTokens> { |
27 | | - const { randomBytes, createHash } = await import('crypto'); |
28 | | - const codeVerifier = randomBytes(32).toString('base64url'); |
29 | | - const codeChallenge = createHash('sha256') |
30 | | - .update(codeVerifier) |
31 | | - .digest('base64url'); |
32 | | - |
33 | | - const state = randomBytes(16).toString('hex'); |
34 | | - |
35 | | - const params = new URLSearchParams({ |
36 | | - client_id: config.clientId, |
37 | | - response_type: 'code', |
38 | | - redirect_uri: `http://localhost:${config.callbackPort}/callback`, |
39 | | - scope: config.scopes.join(' '), |
40 | | - state, |
41 | | - code_challenge: codeChallenge, |
42 | | - code_challenge_method: 'S256', |
43 | | - }); |
44 | | - |
45 | | - const authUrl = `${config.authorizationUrl}?${params}`; |
46 | | - |
47 | | - // Open browser using execFile/spawn instead of exec to prevent shell injection. |
48 | | - // exec() passes the string to a shell, so a crafted authUrl containing shell |
49 | | - // metacharacters (e.g. from a malicious authorization server redirect) could |
50 | | - // execute arbitrary commands. execFile/spawn bypass the shell entirely. (#79) |
51 | | - const { execFile, spawn } = await import('child_process'); |
52 | | - const platform = process.platform; |
| 19 | +interface TokenResponse { |
| 20 | + base_resp?: { status_code: number; status_msg?: string }; |
| 21 | + status: 'pending' | 'success' | string; |
| 22 | + access_token?: string; |
| 23 | + refresh_token?: string; |
| 24 | + expired_in?: number; // absolute Unix ms |
| 25 | + resource_url?: string; |
| 26 | +} |
53 | 27 |
|
54 | | - if (platform === 'darwin') { |
55 | | - execFile('open', [authUrl]); |
56 | | - } else if (platform === 'win32') { |
57 | | - // On Windows, 'start' is a shell built-in — use cmd.exe /c start explicitly. |
58 | | - spawn('cmd.exe', ['/c', 'start', '', authUrl], { shell: false, detached: true }); |
59 | | - } else { |
60 | | - execFile('xdg-open', [authUrl]); |
61 | | - } |
62 | | - process.stderr.write('Opening browser to authenticate with MiniMax...\n'); |
| 28 | +/** |
| 29 | + * Run the OAuth 2.0 Device Authorization Grant against the MiniMax |
| 30 | + * account server for the given region (RFC 8628 + PKCE). |
| 31 | + * |
| 32 | + * Opens the user's browser to the verification URL, displays the |
| 33 | + * user code, and polls /oauth2/token until the user approves. |
| 34 | + */ |
| 35 | +export async function deviceCodeLogin(region: Region): Promise<OAuthCredentials> { |
| 36 | + const host = OAUTH_HOSTS[region]; |
63 | 37 |
|
64 | | - // Start local server to receive callback |
65 | | - const code = await waitForCallback(config.callbackPort, state); |
| 38 | + const { randomBytes, createHash } = await import('crypto'); |
| 39 | + const codeVerifier = randomBytes(32).toString('base64url'); |
| 40 | + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url'); |
| 41 | + const state = randomBytes(16).toString('base64url'); |
66 | 42 |
|
67 | | - // Exchange code for tokens |
68 | | - const tokenRes = await fetch(config.tokenUrl, { |
| 43 | + const codeRes = await fetch(`${host}/oauth2/device/code`, { |
69 | 44 | method: 'POST', |
70 | 45 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
71 | 46 | body: new URLSearchParams({ |
72 | | - grant_type: 'authorization_code', |
73 | | - code, |
74 | | - client_id: config.clientId, |
75 | | - redirect_uri: `http://localhost:${config.callbackPort}/callback`, |
76 | | - code_verifier: codeVerifier, |
| 47 | + client_id: CLIENT_ID, |
| 48 | + scope: SCOPES.join(' '), |
| 49 | + code_challenge: codeChallenge, |
| 50 | + code_challenge_method: 'S256', |
| 51 | + state, |
77 | 52 | }), |
78 | 53 | }); |
79 | 54 |
|
80 | | - if (!tokenRes.ok) { |
81 | | - const body = await tokenRes.text(); |
| 55 | + if (!codeRes.ok) { |
| 56 | + const body = await codeRes.text().catch(() => ''); |
82 | 57 | throw new CLIError( |
83 | | - `OAuth token exchange failed: ${body}`, |
| 58 | + `Failed to start device-code flow (HTTP ${codeRes.status}).`, |
84 | 59 | ExitCode.AUTH, |
| 60 | + body || `URL: ${host}/oauth2/device/code`, |
85 | 61 | ); |
86 | 62 | } |
87 | 63 |
|
88 | | - return (await tokenRes.json()) as OAuthTokens; |
89 | | -} |
90 | | - |
91 | | -async function waitForCallback(port: number, expectedState: string): Promise<string> { |
92 | | - return new Promise<string>((resolve, reject) => { |
93 | | - const timeout = setTimeout(() => { |
94 | | - server.stop(); |
95 | | - reject(new CLIError('OAuth callback timed out.', ExitCode.TIMEOUT)); |
96 | | - }, 120_000); |
97 | | - |
98 | | - const server = Bun.serve({ |
99 | | - port, |
100 | | - fetch(req) { |
101 | | - const url = new URL(req.url); |
102 | | - if (url.pathname !== '/callback') { |
103 | | - return new Response('Not found', { status: 404 }); |
104 | | - } |
105 | | - |
106 | | - const code = url.searchParams.get('code'); |
107 | | - const state = url.searchParams.get('state'); |
108 | | - const error = url.searchParams.get('error'); |
109 | | - |
110 | | - if (error) { |
111 | | - clearTimeout(timeout); |
112 | | - server.stop(); |
113 | | - reject(new CLIError(`OAuth error: ${error}`, ExitCode.AUTH)); |
114 | | - return new Response( |
115 | | - '<html><body><h1>Authentication Failed</h1><p>You can close this tab.</p></body></html>', |
116 | | - { headers: { 'Content-Type': 'text/html' } }, |
117 | | - ); |
118 | | - } |
119 | | - |
120 | | - if (!code || state !== expectedState) { |
121 | | - clearTimeout(timeout); |
122 | | - server.stop(); |
123 | | - reject(new CLIError('Invalid OAuth callback.', ExitCode.AUTH)); |
124 | | - return new Response('Invalid callback', { status: 400 }); |
125 | | - } |
126 | | - |
127 | | - clearTimeout(timeout); |
128 | | - server.stop(); |
129 | | - resolve(code); |
130 | | - return new Response( |
131 | | - '<html><body><h1>Authentication Successful</h1><p>You can close this tab.</p></body></html>', |
132 | | - { headers: { 'Content-Type': 'text/html' } }, |
133 | | - ); |
134 | | - }, |
135 | | - }); |
136 | | - }); |
137 | | -} |
138 | | - |
139 | | -export async function startDeviceCodeFlow( |
140 | | - config: OAuthConfig = DEFAULT_OAUTH_CONFIG, |
141 | | -): Promise<OAuthTokens> { |
142 | | - // Request device code |
143 | | - const codeRes = await fetch(config.deviceCodeUrl, { |
144 | | - method: 'POST', |
145 | | - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
146 | | - body: new URLSearchParams({ |
147 | | - client_id: config.clientId, |
148 | | - scope: config.scopes.join(' '), |
149 | | - }), |
150 | | - }); |
151 | | - |
152 | | - if (!codeRes.ok) { |
153 | | - throw new CLIError( |
154 | | - 'Failed to start device code flow.', |
155 | | - ExitCode.AUTH, |
156 | | - ); |
| 64 | + const data = (await codeRes.json()) as DeviceCodeResponse; |
| 65 | + if (data.state !== state) { |
| 66 | + throw new CLIError('OAuth state mismatch.', ExitCode.AUTH); |
157 | 67 | } |
158 | 68 |
|
159 | | - const { device_code, user_code, verification_uri, interval, expires_in } = |
160 | | - (await codeRes.json()) as { |
161 | | - device_code: string; |
162 | | - user_code: string; |
163 | | - verification_uri: string; |
164 | | - interval: number; |
165 | | - expires_in: number; |
166 | | - }; |
167 | | - |
168 | | - process.stderr.write(`\nVisit: ${verification_uri}\n`); |
169 | | - process.stderr.write(`Enter code: ${user_code}\n`); |
| 69 | + openBrowser(data.verification_uri); |
| 70 | + process.stderr.write(`\nOpened: ${data.verification_uri}\n`); |
| 71 | + process.stderr.write(`Code: ${data.user_code}\n`); |
| 72 | + process.stderr.write(`Client: ${CLIENT_NAME}\n`); |
170 | 73 | process.stderr.write('Waiting for authorization...\n'); |
171 | 74 |
|
172 | | - // Poll for authorization |
173 | | - const deadline = Date.now() + expires_in * 1000; |
174 | | - const pollInterval = (interval || 5) * 1000; |
| 75 | + const deadline = data.expired_in; |
| 76 | + const intervalMs = data.interval || 3000; |
175 | 77 |
|
176 | 78 | while (Date.now() < deadline) { |
177 | | - await new Promise(r => setTimeout(r, pollInterval)); |
| 79 | + await sleep(intervalMs); |
178 | 80 |
|
179 | | - const tokenRes = await fetch(config.tokenUrl, { |
| 81 | + const tokRes = await fetch(`${host}/oauth2/token`, { |
180 | 82 | method: 'POST', |
181 | 83 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
182 | 84 | body: new URLSearchParams({ |
183 | 85 | grant_type: 'urn:ietf:params:oauth:grant-type:device_code', |
184 | | - device_code, |
185 | | - client_id: config.clientId, |
| 86 | + client_id: CLIENT_ID, |
| 87 | + user_code: data.user_code, |
| 88 | + code_verifier: codeVerifier, |
186 | 89 | }), |
187 | 90 | }); |
188 | 91 |
|
189 | | - if (tokenRes.ok) { |
190 | | - return (await tokenRes.json()) as OAuthTokens; |
| 92 | + if (!tokRes.ok) { |
| 93 | + throw new CLIError( |
| 94 | + `Device-code authorization failed (HTTP ${tokRes.status}).`, |
| 95 | + ExitCode.AUTH, |
| 96 | + ); |
191 | 97 | } |
192 | 98 |
|
193 | | - const err = (await tokenRes.json()) as { error: string }; |
194 | | - if (err.error === 'authorization_pending') continue; |
195 | | - if (err.error === 'slow_down') { |
196 | | - await new Promise(r => setTimeout(r, 5000)); |
197 | | - continue; |
| 99 | + const tok = (await tokRes.json()) as TokenResponse; |
| 100 | + if (tok.status === 'pending') continue; |
| 101 | + if (tok.status === 'success' && tok.access_token) { |
| 102 | + return { |
| 103 | + access_token: tok.access_token, |
| 104 | + refresh_token: tok.refresh_token ?? '', |
| 105 | + expires_at: new Date(tok.expired_in ?? Date.now()).toISOString(), |
| 106 | + region, |
| 107 | + resource_url: tok.resource_url, |
| 108 | + }; |
198 | 109 | } |
199 | | - |
200 | | - throw new CLIError( |
201 | | - `Device code authorization failed: ${err.error}`, |
202 | | - ExitCode.AUTH, |
203 | | - ); |
| 110 | + throw new CLIError(`Device-code authorization failed: ${tok.status}`, ExitCode.AUTH); |
204 | 111 | } |
205 | 112 |
|
206 | | - throw new CLIError('Device code authorization timed out.', ExitCode.TIMEOUT); |
| 113 | + throw new CLIError('Device-code authorization timed out.', ExitCode.TIMEOUT); |
| 114 | +} |
| 115 | + |
| 116 | +function sleep(ms: number): Promise<void> { |
| 117 | + return new Promise(r => setTimeout(r, ms)); |
| 118 | +} |
| 119 | + |
| 120 | +/** |
| 121 | + * Open `url` in the user's default browser without going through a shell. |
| 122 | + * Using execFile/spawn (not exec) avoids shell-injection via crafted URLs (#79). |
| 123 | + */ |
| 124 | +function openBrowser(url: string): void { |
| 125 | + // eslint-disable-next-line @typescript-eslint/no-require-imports |
| 126 | + const { execFile, spawn } = require('child_process') as typeof import('child_process'); |
| 127 | + if (process.platform === 'darwin') { |
| 128 | + execFile('open', [url]); |
| 129 | + } else if (process.platform === 'win32') { |
| 130 | + spawn('cmd.exe', ['/c', 'start', '', url], { shell: false, detached: true }); |
| 131 | + } else { |
| 132 | + execFile('xdg-open', [url]); |
| 133 | + } |
207 | 134 | } |
0 commit comments