Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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.

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
63 changes: 46 additions & 17 deletions src/auth/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,37 @@ import { saveCredentials } from "./credentials";
import { CLIError } from "../errors/base";
import { ExitCode } from "../errors/codes";

// OAuth config — endpoints TBD pending MiniMax OAuth documentation
const TOKEN_URL = "https://api.minimax.io/v1/oauth/token";
const DEFAULT_TOKEN_URL = 'https://account.minimax.io/oauth2/token';
const DEFAULT_CLIENT_ID = '659cf4c1-615c-45f6-a5f6-4bf15eb476e5';

const MAX_REFRESH_RETRIES = 2;
const RETRY_DELAY_MS = 500;

export async function refreshAccessToken(
refreshToken: string,
tokenUrl: string = DEFAULT_TOKEN_URL,
clientId: string = DEFAULT_CLIENT_ID,
): Promise<OAuthTokens> {
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';

let lastErr: Error | null = null;

for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
if (attempt > 0) {
// Exponential backoff before retry
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * attempt));
}

let res: Response;
try {
res = await fetch(TOKEN_URL, {
res = await fetch(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: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
}),
signal: AbortSignal.timeout(10_000),
});
Expand All @@ -42,11 +48,10 @@ export async function refreshAccessToken(
? "Token refresh timed out — auth server did not respond within 10 s."
: `Token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
);
continue; // retry
continue;
}

if (!res.ok) {
// 4xx errors won't recover with retry
if (res.status >= 400 && res.status < 500) {
throw new CLIError(
"OAuth session expired and could not be refreshed.",
Expand All @@ -55,37 +60,61 @@ export async function refreshAccessToken(
);
}
lastErr = new Error(`Token refresh failed: HTTP ${res.status}`);
continue; // retry 5xx errors
continue;
}

const body = (await res.json()) as {
status: string;
access_token?: string;
refresh_token?: string;
expired_in?: number;
resource_url?: string;
};

if (body.status !== 'success' || !body.access_token) {
throw new CLIError(
'OAuth refresh failed.',
ExitCode.AUTH,
'Re-authenticate: mmx auth login',
);
}

const data = (await res.json()) as OAuthTokens;
return data;
return {
access_token: body.access_token,
refresh_token: body.refresh_token ?? refreshToken,
expired_in: body.expired_in ?? 0,
token_type: 'Bearer',
resource_url: body.resource_url,
};
}

// All retries exhausted
throw new CLIError(
`Token refresh failed after ${MAX_REFRESH_RETRIES + 1} attempts: ${lastErr?.message}`,
ExitCode.AUTH,
"Check your network connection.\nRe-authenticate: mmx auth login",
);
}

export async function ensureFreshToken(creds: CredentialFile): Promise<string> {
export async function ensureFreshToken(
creds: CredentialFile,
tokenUrl?: string,
clientId?: string,
): Promise<string> {
const expiresAt = new Date(creds.expires_at).getTime();
const bufferMs = 5 * 60 * 1000; // 5 minutes
const bufferMs = 5 * 60 * 1000;

if (Date.now() < expiresAt - bufferMs) {
return creds.access_token;
}

// Token expired or about to expire — refresh
const tokens = await refreshAccessToken(creds.refresh_token);
const tokens = await refreshAccessToken(creds.refresh_token, tokenUrl, clientId);

const updated: CredentialFile = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
token_type: "Bearer",
expires_at: new Date(tokens.expired_in).toISOString(), // expired_in is Unix timestamp (ms)
token_type: 'Bearer',
resource_url: tokens.resource_url ?? creds.resource_url,
account: creds.account,
};

Expand Down
Loading