Skip to content
Closed
410 changes: 390 additions & 20 deletions package-lock.json

Large diffs are not rendered by default.

112 changes: 74 additions & 38 deletions src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,19 @@ 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;
clientName: string;
authorizationUrl: 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,
config: OAuthConfig,
): Promise<OAuthTokens> {
const { randomBytes, createHash } = await import('crypto');
const codeVerifier = randomBytes(32).toString('base64url');
Expand Down Expand Up @@ -137,68 +129,112 @@ async function waitForCallback(port: number, expectedState: string): Promise<str
}

export async function startDeviceCodeFlow(
config: OAuthConfig = DEFAULT_OAUTH_CONFIG,
config: OAuthConfig,
): Promise<OAuthTokens> {
// Request device code
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('base64url');

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

// 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
2 changes: 1 addition & 1 deletion src/auth/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export async function resolveCredential(config: Config): Promise<ResolvedCredent
// 2. OAuth credentials file
const oauth = await loadCredentials();
if (oauth) {
const token = await ensureFreshToken(oauth);
const token = await ensureFreshToken(oauth, `${config.oauthApiHost}/oauth2/token`);
return { token, method: 'oauth', source: 'credentials.json' };
}

Expand Down
52 changes: 49 additions & 3 deletions src/auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ import { isInteractive } from '../utils/env';
import { maskToken } from '../utils/token';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { startDeviceCodeFlow, type OAuthConfig } from './oauth';
import { saveCredentials, loadCredentials } from './credentials';
import type { CredentialFile } from './types';

export async function ensureApiKey(config: Config): Promise<void> {
export async function ensureAuth(config: Config): Promise<void> {
if (config.apiKey || config.fileApiKey) return;

// Check existing OAuth credentials
const existingOAuth = await loadCredentials();
if (existingOAuth) return;

const envKey = process.env.MINIMAX_API_KEY;
let key: string | undefined;

Expand All @@ -26,11 +33,50 @@ export async function ensureApiKey(config: Config): Promise<void> {
if (!key) {
if (!isInteractive({ nonInteractive: config.nonInteractive })) {
throw new CLIError(
'No API key found.',
'No credentials found.',
ExitCode.AUTH,
'Set env var: export MINIMAX_API_KEY=sk-xxxxx\nPass directly: --api-key sk-xxxxx',
'Log in: mmx auth login\nPass directly: --api-key sk-xxxxx',
);
}

const { select } = await import('@clack/prompts');
const method = await select({
message: 'How would you like to authenticate?',
options: [
{ value: 'oauth', label: 'Log in with MiniMax account (OAuth)' },
{ value: 'api-key', label: 'Enter API key manually' },
],
});

if (typeof method === 'symbol') {
// User pressed Ctrl+C
throw new CLIError('Authentication cancelled.', ExitCode.AUTH);
}

if (method === 'oauth') {
const oauthConfig: OAuthConfig = {
clientId: '659cf4c1-615c-45f6-a5f6-4bf15eb476e5',
clientName: 'MiniMax CLI',
authorizationUrl: `${config.platformHost}/oauth-authorize`,
tokenUrl: `${config.oauthApiHost}/oauth2/token`,
deviceCodeUrl: `${config.oauthApiHost}/oauth2/device/code`,
scopes: ['openid', 'profile', 'coding_plan'],
callbackPort: 18991,
};
const tokens = await startDeviceCodeFlow(oauthConfig);
const creds: CredentialFile = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: new Date(tokens.expired_in).toISOString(),
token_type: 'Bearer',
resource_url: tokens.resource_url,
};
await saveCredentials(creds);
process.stderr.write('Logged in successfully.\n');
return;
}

// api-key method
const input = await promptText({ message: 'Enter your MiniMax API key:' });
if (!input) throw new CLIError('API key is required.', ExitCode.AUTH);
key = input;
Expand Down
4 changes: 3 additions & 1 deletion src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ export type AuthMethod = 'api-key' | 'oauth';
export interface OAuthTokens {
access_token: string;
refresh_token: string;
expires_in: number;
expired_in: number; // milliseconds
token_type: 'Bearer';
resource_url?: string;
}

export interface CredentialFile {
access_token: string;
refresh_token: string;
expires_at: string; // ISO 8601
token_type: 'Bearer';
resource_url?: string;
account?: string;
}

Expand Down
Loading