Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
619 changes: 602 additions & 17 deletions package-lock.json

Large diffs are not rendered by default.

65 changes: 24 additions & 41 deletions src/auth/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,34 @@
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, statSync } from 'fs';
import { getCredentialsPath, ensureConfigDir } from '../config/paths';
import { readConfigFile, writeConfigFile } from '../config/loader';
import type { CredentialFile } from './types';

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

try {
checkPermissions(path);
const raw = readFileSync(path, 'utf-8');
const data = JSON.parse(raw) as CredentialFile;
if (!data.access_token || !data.refresh_token) return null;
return data;
} catch (err) {
const e = err as Error;
if (e instanceof SyntaxError || e.message.includes('JSON')) {
process.stderr.write(`Warning: credentials file is corrupted. Run 'mmx auth logout' to reset.\n`);
}
return null;
}
export async function loadCredentials(): Promise<CredentialFile | null> {
const cfg = readConfigFile();
if (!cfg.oauth) return null;
return {
access_token: cfg.oauth.access_token,
refresh_token: cfg.oauth.refresh_token,
expires_at: cfg.oauth.expires_at,
token_type: cfg.oauth.token_type,
resource_url: cfg.oauth.resource_url,
account: cfg.oauth.account,
};
}

export async function saveCredentials(creds: CredentialFile): Promise<void> {
await ensureConfigDir();
const path = getCredentialsPath();
const tmp = path + '.tmp';
writeFileSync(tmp, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
renameSync(tmp, path);
const existing = readConfigFile() as Record<string, unknown>;
existing.oauth = creds;
await writeConfigFile(existing);
}

export async function clearCredentials(): Promise<void> {
const path = getCredentialsPath();
if (existsSync(path)) {
unlinkSync(path);
}
}

function checkPermissions(path: string): void {
try {
const stat = statSync(path);
const mode = stat.mode & 0o777;
if (mode !== 0o600) {
process.stderr.write(
`Warning: ${path} has permissions ${mode.toString(8)}, expected 600.\n`,
);
}
} catch {
// Ignore permission check errors on platforms that don't support it
}
const existing = readConfigFile() as Record<string, unknown>;
if (!('oauth' in existing)) return;
delete existing.oauth;
await writeConfigFile(existing);
}
213 changes: 66 additions & 147 deletions src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,203 +2,122 @@ import type { OAuthTokens } from './types';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';

// OAuth configuration — exact endpoints TBD pending MiniMax OAuth docs
// OAuth configuration
export interface OAuthConfig {
clientId: string;
authorizationUrl: string;
clientName: string;
tokenUrl: string;
deviceCodeUrl: string;
scopes: string[];
callbackPort: number;
}

const DEFAULT_OAUTH_CONFIG: OAuthConfig = {
clientId: 'mmx-cli',
authorizationUrl: 'https://platform.minimax.io/oauth/authorize',
tokenUrl: 'https://api.minimax.io/v1/oauth/token',
deviceCodeUrl: 'https://api.minimax.io/v1/oauth/device/code',
scopes: ['api'],
callbackPort: 18991,
};

export async function startBrowserFlow(
config: OAuthConfig = DEFAULT_OAUTH_CONFIG,
export async function startDeviceCodeFlow(
config: OAuthConfig,
): Promise<OAuthTokens> {
const { randomBytes, createHash } = await import('crypto');
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');

const state = randomBytes(16).toString('hex');

const params = new URLSearchParams({
client_id: config.clientId,
response_type: 'code',
redirect_uri: `http://localhost:${config.callbackPort}/callback`,
scope: config.scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});

const authUrl = `${config.authorizationUrl}?${params}`;

// Open browser using execFile/spawn instead of exec to prevent shell injection.
// exec() passes the string to a shell, so a crafted authUrl containing shell
// metacharacters (e.g. from a malicious authorization server redirect) could
// execute arbitrary commands. execFile/spawn bypass the shell entirely. (#79)
const { execFile, spawn } = await import('child_process');
const platform = process.platform;

if (platform === 'darwin') {
execFile('open', [authUrl]);
} else if (platform === 'win32') {
// On Windows, 'start' is a shell built-in — use cmd.exe /c start explicitly.
spawn('cmd.exe', ['/c', 'start', '', authUrl], { shell: false, detached: true });
} else {
execFile('xdg-open', [authUrl]);
}
process.stderr.write('Opening browser to authenticate with MiniMax...\n');

// Start local server to receive callback
const code = await waitForCallback(config.callbackPort, state);

// Exchange code for tokens
const tokenRes = await fetch(config.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: config.clientId,
redirect_uri: `http://localhost:${config.callbackPort}/callback`,
code_verifier: codeVerifier,
}),
});

if (!tokenRes.ok) {
const body = await tokenRes.text();
throw new CLIError(
`OAuth token exchange failed: ${body}`,
ExitCode.AUTH,
);
}

return (await tokenRes.json()) as OAuthTokens;
}
const state = randomBytes(16).toString('base64url');

async function waitForCallback(port: number, expectedState: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
server.stop();
reject(new CLIError('OAuth callback timed out.', ExitCode.TIMEOUT));
}, 120_000);

const server = Bun.serve({
port,
fetch(req) {
const url = new URL(req.url);
if (url.pathname !== '/callback') {
return new Response('Not found', { status: 404 });
}

const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');

if (error) {
clearTimeout(timeout);
server.stop();
reject(new CLIError(`OAuth error: ${error}`, ExitCode.AUTH));
return new Response(
'<html><body><h1>Authentication Failed</h1><p>You can close this tab.</p></body></html>',
{ headers: { 'Content-Type': 'text/html' } },
);
}

if (!code || state !== expectedState) {
clearTimeout(timeout);
server.stop();
reject(new CLIError('Invalid OAuth callback.', ExitCode.AUTH));
return new Response('Invalid callback', { status: 400 });
}

clearTimeout(timeout);
server.stop();
resolve(code);
return new Response(
'<html><body><h1>Authentication Successful</h1><p>You can close this tab.</p></body></html>',
{ headers: { 'Content-Type': 'text/html' } },
);
},
});
});
}
const lane = process.env.BEDROCK_LANE;
const extraHeaders: Record<string, string> = lane ? { bedrock_lane: lane } : {};
if (process.env.X_USER_PRE) extraHeaders['X-User-Pre'] = 'true';

export async function startDeviceCodeFlow(
config: OAuthConfig = DEFAULT_OAUTH_CONFIG,
): Promise<OAuthTokens> {
// Request device code
// Request device code with PKCE
const codeRes = await fetch(config.deviceCodeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders },
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scopes.join(' '),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
}),
});

if (!codeRes.ok) {
const body = await codeRes.text().catch(() => '');
throw new CLIError(
'Failed to start device code flow.',
`Failed to start device code flow: HTTP ${codeRes.status} ${body}`,
ExitCode.AUTH,
`URL: ${config.deviceCodeUrl}`,
);
}

const { device_code, user_code, verification_uri, interval, expires_in } =
(await codeRes.json()) as {
device_code: string;
user_code: string;
verification_uri: string;
interval: number;
expires_in: number;
};
const data = (await codeRes.json()) as {
user_code: string;
verification_uri: string;
expired_in: number; // Unix timestamp (ms)
interval: number; // milliseconds
state: string;
};

if (data.state !== state) {
throw new CLIError('OAuth state mismatch: possible CSRF attack.', ExitCode.AUTH);
}

const url = data.verification_uri;

process.stderr.write(`\nVisit: ${verification_uri}\n`);
process.stderr.write(`Enter code: ${user_code}\n`);
const { exec } = await import('child_process');
const openCmd = process.platform === 'darwin' ? 'open' :
process.platform === 'win32' ? 'start' : 'xdg-open';
exec(`${openCmd} "${url}"`);

process.stderr.write(`\nOpened: ${url}\n`);
process.stderr.write(`Enter code: ${data.user_code}\n`);
process.stderr.write('Waiting for authorization...\n');

// Poll for authorization
const deadline = Date.now() + expires_in * 1000;
const pollInterval = (interval || 5) * 1000;
// Poll for authorization (expired_in is Unix timestamp in ms)
const deadline = data.expired_in;
const pollInterval = data.interval || 5000;

while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, pollInterval));

const tokenRes = await fetch(config.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code,
client_id: config.clientId,
user_code: data.user_code,
code_verifier: codeVerifier,
}),
});

if (tokenRes.ok) {
return (await tokenRes.json()) as OAuthTokens;
if (!tokenRes.ok) {
throw new CLIError(
`Device code authorization failed: HTTP ${tokenRes.status}`,
ExitCode.AUTH,
);
}

const err = (await tokenRes.json()) as { error: string };
if (err.error === 'authorization_pending') continue;
if (err.error === 'slow_down') {
await new Promise(r => setTimeout(r, 5000));
continue;
const tokenData = (await tokenRes.json()) as {
status: string;
access_token?: string;
refresh_token?: string;
expired_in?: number;
resource_url?: string;
};

if (tokenData.status === 'success' && tokenData.access_token) {
return {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token ?? '',
expired_in: tokenData.expired_in ?? 0,
token_type: 'Bearer',
resource_url: tokenData.resource_url,
};
}

if (tokenData.status === 'pending') continue;

throw new CLIError(
`Device code authorization failed: ${err.error}`,
`Device code authorization failed: ${tokenData.status}`,
ExitCode.AUTH,
);
}
Expand Down
Loading
Loading