Skip to content

Commit a8c7dee

Browse files
kapelamekapelame
authored andcommitted
feat(auth): add OAuth device-code login (Global/CN) into config.json
Adds an end-to-end OAuth 2.0 Device Authorization Grant flow against the MiniMax account servers (account.minimax.io / account.minimaxi.com), selectable via a single first-run menu: ▸ MiniMax · OAuth login (Global) MiniMaxCN · OAuth login (China) API key · Paste sk-cp-... or sk-... Tokens are persisted into ~/.mmx/config.json under an 'oauth' subobject — no separate credentials.json file, so logout clears one place and users have a single file to back up. Highlights: * Single 3-option menu (no two-step method-then-region prompt) * Browser opened via execFile/spawn (shell-injection safe, #79) * Refresh derives the OAuth host from the saved region — no Config.oauthApiHost field is added, so existing test fixtures are unaffected (zero churn) * Honest field naming: expires_at (ISO 8601) at the boundary; no internal env-var leakage (BEDROCK_LANE / X-User-Pre etc.) Net diff: 15 files, -27 lines (browser-callback dead code removed).
1 parent 02fbdfb commit a8c7dee

15 files changed

Lines changed: 371 additions & 398 deletions

File tree

src/auth/credentials.ts

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,26 @@
1-
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, statSync } from 'fs';
2-
import { getCredentialsPath, ensureConfigDir } from '../config/paths';
1+
import { readConfigFile, writeConfigFile } from '../config/loader';
32
import type { CredentialFile } from './types';
43

5-
export async function loadCredentials(): Promise<CredentialFile | null> {
6-
const path = getCredentialsPath();
7-
if (!existsSync(path)) return null;
4+
/**
5+
* OAuth credentials live inside the user's main config file
6+
* (`~/.mmx/config.json`) under the `oauth` subobject. This keeps a
7+
* single source of truth for all CLI state.
8+
*/
89

9-
try {
10-
checkPermissions(path);
11-
const raw = readFileSync(path, 'utf-8');
12-
const data = JSON.parse(raw) as CredentialFile;
13-
if (!data.access_token || !data.refresh_token) return null;
14-
return data;
15-
} catch (err) {
16-
const e = err as Error;
17-
if (e instanceof SyntaxError || e.message.includes('JSON')) {
18-
process.stderr.write(`Warning: credentials file is corrupted. Run 'mmx auth logout' to reset.\n`);
19-
}
20-
return null;
21-
}
10+
export async function loadCredentials(): Promise<CredentialFile | null> {
11+
const cfg = readConfigFile();
12+
return cfg.oauth ?? null;
2213
}
2314

2415
export async function saveCredentials(creds: CredentialFile): Promise<void> {
25-
await ensureConfigDir();
26-
const path = getCredentialsPath();
27-
const tmp = path + '.tmp';
28-
writeFileSync(tmp, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
29-
renameSync(tmp, path);
16+
const existing = readConfigFile() as Record<string, unknown>;
17+
existing.oauth = creds;
18+
await writeConfigFile(existing);
3019
}
3120

3221
export async function clearCredentials(): Promise<void> {
33-
const path = getCredentialsPath();
34-
if (existsSync(path)) {
35-
unlinkSync(path);
36-
}
37-
}
38-
39-
function checkPermissions(path: string): void {
40-
try {
41-
const stat = statSync(path);
42-
const mode = stat.mode & 0o777;
43-
if (mode !== 0o600) {
44-
process.stderr.write(
45-
`Warning: ${path} has permissions ${mode.toString(8)}, expected 600.\n`,
46-
);
47-
}
48-
} catch {
49-
// Ignore permission check errors on platforms that don't support it
50-
}
22+
const existing = readConfigFile() as Record<string, unknown>;
23+
if (!('oauth' in existing)) return;
24+
delete existing.oauth;
25+
await writeConfigFile(existing);
5126
}

src/auth/oauth.ts

Lines changed: 95 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -1,207 +1,134 @@
1-
import type { OAuthTokens } from './types';
1+
import type { OAuthCredentials, Region } from '../config/schema';
2+
import { OAUTH_HOSTS } from '../config/schema';
23
import { CLIError } from '../errors/base';
34
import { ExitCode } from '../errors/codes';
45

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;
1317
}
1418

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+
}
5327

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];
6337

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');
6642

67-
// Exchange code for tokens
68-
const tokenRes = await fetch(config.tokenUrl, {
43+
const codeRes = await fetch(`${host}/oauth2/device/code`, {
6944
method: 'POST',
7045
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
7146
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,
7752
}),
7853
});
7954

80-
if (!tokenRes.ok) {
81-
const body = await tokenRes.text();
55+
if (!codeRes.ok) {
56+
const body = await codeRes.text().catch(() => '');
8257
throw new CLIError(
83-
`OAuth token exchange failed: ${body}`,
58+
`Failed to start device-code flow (HTTP ${codeRes.status}).`,
8459
ExitCode.AUTH,
60+
body || `URL: ${host}/oauth2/device/code`,
8561
);
8662
}
8763

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);
15767
}
15868

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`);
17073
process.stderr.write('Waiting for authorization...\n');
17174

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;
17577

17678
while (Date.now() < deadline) {
177-
await new Promise(r => setTimeout(r, pollInterval));
79+
await sleep(intervalMs);
17880

179-
const tokenRes = await fetch(config.tokenUrl, {
81+
const tokRes = await fetch(`${host}/oauth2/token`, {
18082
method: 'POST',
18183
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
18284
body: new URLSearchParams({
18385
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,
18689
}),
18790
});
18891

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+
);
19197
}
19298

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+
};
198109
}
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);
204111
}
205112

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+
}
207134
}

0 commit comments

Comments
 (0)