Skip to content

Commit b51d772

Browse files
kapelamekapelame
authored andcommitted
feat(minimax): migrate OAuth to account/oauth2 endpoints + add refresh
Migrates the MiniMax portal OAuth flow from the legacy api.{minimax.io,minimaxi.com}/oauth/* surface to the canonical account.{minimax.io,minimaxi.com}/oauth2/* surface, and finally implements the refresh path that provider-registration.ts:176 already promised users ("MiniMax OAuth tokens auto-refresh"). The legacy endpoints still respond, but `account/oauth2/*` is what MiniMax platform team treats as canonical and what the existing mmx-cli + the in-flight Hermes / opencode integrations target. OpenClaw's `78257093-7e40-4613-99e0-527b14b39113` client_id is verified registered on the new backend for both global + CN (POST account.{minimax.io,minimaxi.com}/oauth2/device/code with this client_id returns HTTP 200 + valid user_code). Endpoint migration (extensions/minimax/oauth.ts): | | Before | After | |---|---|---| | Global base | `https://api.minimax.io` | `https://account.minimax.io` | | CN base | `https://api.minimaxi.com` | `https://account.minimaxi.com` | | Device code | `{base}/oauth/code` | `{base}/oauth2/device/code` | | Token | `{base}/oauth/token` | `{base}/oauth2/token` | | Grant type | `…:grant-type:user_code` | `…:grant-type:device_code` (RFC 8628) | | Scope | `group_id profile model.completion` | `openid profile coding_plan` | | Body extra | `response_type: "code"` | (removed — new endpoint doesn't expect it) | New refresh path: - `oauth.ts` exports `refreshMiniMaxPortalOAuth({refreshToken, region})` → MiniMaxOAuthToken. Throws clearly when the server signals the refresh_token is no longer valid (90-day window elapsed, token rotated, grant revoked) — caller should treat as re-login required. - `oauth.runtime.ts` re-exports it for the existing lazy-import boundary. - `provider-registration.ts`: - Login flow now persists `enterpriseUrl: baseUrl` on the OAuthCredential via `credentialExtra`, so the refresh callback can derive which region's OAuth backend to hit (the refresh contract only receives the credential). - PORTAL provider's `registerProvider({...})` block adds `refreshOAuth: async (cred) => await refreshMiniMaxPortalOAuthCredential(cred)`, matching the established pattern from `extensions/openai/openai-codex-provider.ts:582`. The wrapper derives region from cred.enterpriseUrl (`*.minimaxi.com` ⇒ CN, else global), calls the refresh, returns the rotated credential. Tests: - New `extensions/minimax/oauth.test.ts` (6 tests) covers: - global / CN endpoint URLs and grant_type=refresh_token body - server omitting refresh_token → fall back to previous one - non-2xx → throws (re-login signal) - status≠"success" with HTTP 200 → throws (refresh_token_reused etc.) - missing access_token in success payload → throws - `extensions/minimax/index.test.ts` mock for `./oauth.runtime.js` now also stubs `refreshMiniMaxPortalOAuth` so tests that touch the new re-exported symbol don't see undefined.
1 parent 916fc3d commit b51d772

6 files changed

Lines changed: 225 additions & 9 deletions

File tree

extensions/minimax/index.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ vi.mock("./oauth.runtime.js", () => ({
1515
expires: Date.now() + 60_000,
1616
resourceUrl: "https://api.minimax.io/anthropic",
1717
})),
18+
refreshMiniMaxPortalOAuth: vi.fn(async () => ({
19+
access: "minimax-oauth-access-token-refreshed",
20+
refresh: "minimax-oauth-refresh-token-rotated",
21+
expires: Date.now() + 60_000,
22+
resourceUrl: "https://api.minimax.io/anthropic",
23+
})),
1824
}));
1925

2026
const minimaxProviderPlugin = {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { loginMiniMaxPortalOAuth } from "./oauth.js";
1+
export { loginMiniMaxPortalOAuth, refreshMiniMaxPortalOAuth } from "./oauth.js";

extensions/minimax/oauth.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2+
import { refreshMiniMaxPortalOAuth } from "./oauth.js";
3+
4+
const ORIGINAL_FETCH = globalThis.fetch;
5+
6+
function makeJsonResponse(body: unknown, init: ResponseInit = {}): Response {
7+
return new Response(JSON.stringify(body), {
8+
status: 200,
9+
headers: { "Content-Type": "application/json" },
10+
...init,
11+
});
12+
}
13+
14+
beforeEach(() => {
15+
vi.useFakeTimers({ shouldAdvanceTime: true });
16+
});
17+
18+
afterEach(() => {
19+
globalThis.fetch = ORIGINAL_FETCH;
20+
vi.restoreAllMocks();
21+
vi.useRealTimers();
22+
});
23+
24+
describe("refreshMiniMaxPortalOAuth", () => {
25+
it("posts to account.minimax.io/oauth2/token for global region with refresh_token grant", async () => {
26+
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
27+
const body = (init?.body as string) ?? "";
28+
expect(body).toContain("grant_type=refresh_token");
29+
expect(body).toContain("client_id=78257093-7e40-4613-99e0-527b14b39113");
30+
expect(body).toContain("refresh_token=stale-rt");
31+
return makeJsonResponse({
32+
status: "success",
33+
access_token: "fresh-access",
34+
refresh_token: "rotated-rt",
35+
expired_in: 1_700_000_000_000,
36+
resource_url: "https://api.minimax.io/anthropic",
37+
});
38+
});
39+
globalThis.fetch = fetchMock as typeof fetch;
40+
41+
const token = await refreshMiniMaxPortalOAuth({
42+
refreshToken: "stale-rt",
43+
region: "global",
44+
});
45+
46+
expect(fetchMock).toHaveBeenCalledTimes(1);
47+
const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
48+
expect(url).toBe("https://account.minimax.io/oauth2/token");
49+
expect(token.access).toBe("fresh-access");
50+
expect(token.refresh).toBe("rotated-rt");
51+
expect(token.expires).toBe(1_700_000_000_000);
52+
expect(token.resourceUrl).toBe("https://api.minimax.io/anthropic");
53+
});
54+
55+
it("posts to account.minimaxi.com/oauth2/token for cn region", async () => {
56+
const fetchMock = vi.fn(async () =>
57+
makeJsonResponse({
58+
status: "success",
59+
access_token: "cn-access",
60+
refresh_token: "cn-rotated-rt",
61+
expired_in: 1_700_000_000_000,
62+
resource_url: "https://api.minimaxi.com/anthropic",
63+
}),
64+
);
65+
globalThis.fetch = fetchMock as typeof fetch;
66+
67+
const token = await refreshMiniMaxPortalOAuth({
68+
refreshToken: "stale-rt",
69+
region: "cn",
70+
});
71+
72+
const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
73+
expect(url).toBe("https://account.minimaxi.com/oauth2/token");
74+
expect(token.access).toBe("cn-access");
75+
expect(token.resourceUrl).toBe("https://api.minimaxi.com/anthropic");
76+
});
77+
78+
it("falls back to the previous refresh_token if the server omits a new one", async () => {
79+
globalThis.fetch = (async () =>
80+
makeJsonResponse({
81+
status: "success",
82+
access_token: "fresh-access",
83+
// refresh_token intentionally missing
84+
expired_in: 1_700_000_000_000,
85+
})) as typeof fetch;
86+
87+
const token = await refreshMiniMaxPortalOAuth({
88+
refreshToken: "old-rt-stays",
89+
region: "global",
90+
});
91+
92+
expect(token.refresh).toBe("old-rt-stays");
93+
});
94+
95+
it("throws on non-2xx (caller should treat as re-login required)", async () => {
96+
globalThis.fetch = (async () =>
97+
new Response(
98+
JSON.stringify({
99+
base_resp: { status_code: 1, status_msg: "invalid_grant" },
100+
}),
101+
{ status: 400, headers: { "Content-Type": "application/json" } },
102+
)) as typeof fetch;
103+
104+
await expect(
105+
refreshMiniMaxPortalOAuth({ refreshToken: "rotten-rt", region: "global" }),
106+
).rejects.toThrow(/invalid_grant/);
107+
});
108+
109+
it("throws when status is not success even with HTTP 200", async () => {
110+
globalThis.fetch = (async () =>
111+
makeJsonResponse({
112+
status: "error",
113+
base_resp: { status_code: 5, status_msg: "refresh_token_reused" },
114+
})) as typeof fetch;
115+
116+
await expect(
117+
refreshMiniMaxPortalOAuth({ refreshToken: "rt", region: "global" }),
118+
).rejects.toThrow(/incomplete or unsuccessful/);
119+
});
120+
121+
it("throws when payload omits access_token (incomplete response)", async () => {
122+
globalThis.fetch = (async () =>
123+
makeJsonResponse({
124+
status: "success",
125+
// access_token missing
126+
refresh_token: "rotated-rt",
127+
expired_in: 1_700_000_000_000,
128+
})) as typeof fetch;
129+
130+
await expect(
131+
refreshMiniMaxPortalOAuth({ refreshToken: "rt", region: "global" }),
132+
).rejects.toThrow(/incomplete or unsuccessful/);
133+
});
134+
});

extensions/minimax/oauth.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,23 @@ export type MiniMaxRegion = "cn" | "global";
66

77
const MINIMAX_OAUTH_CONFIG = {
88
cn: {
9-
baseUrl: "https://api.minimaxi.com",
9+
baseUrl: "https://account.minimaxi.com",
1010
clientId: "78257093-7e40-4613-99e0-527b14b39113",
1111
},
1212
global: {
13-
baseUrl: "https://api.minimax.io",
13+
baseUrl: "https://account.minimax.io",
1414
clientId: "78257093-7e40-4613-99e0-527b14b39113",
1515
},
1616
} as const;
1717

18-
const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion";
19-
const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code";
18+
const MINIMAX_OAUTH_SCOPE = "openid profile coding_plan";
19+
const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
2020

2121
function getOAuthEndpoints(region: MiniMaxRegion) {
2222
const config = MINIMAX_OAUTH_CONFIG[region];
2323
return {
24-
codeEndpoint: `${config.baseUrl}/oauth/code`,
25-
tokenEndpoint: `${config.baseUrl}/oauth/token`,
24+
codeEndpoint: `${config.baseUrl}/oauth2/device/code`,
25+
tokenEndpoint: `${config.baseUrl}/oauth2/token`,
2626
clientId: config.clientId,
2727
baseUrl: config.baseUrl,
2828
};
@@ -71,7 +71,6 @@ async function requestOAuthCode(params: {
7171
"x-request-id": randomUUID(),
7272
},
7373
body: toFormUrlEncoded({
74-
response_type: "code",
7574
client_id: endpoints.clientId,
7675
scope: MINIMAX_OAUTH_SCOPE,
7776
code_challenge: params.challenge,
@@ -231,3 +230,43 @@ export async function loginMiniMaxPortalOAuth(params: {
231230

232231
throw new Error("MiniMax OAuth timed out before authorization completed.");
233232
}
233+
234+
export async function refreshMiniMaxPortalOAuth(params: {
235+
refreshToken: string;
236+
region: MiniMaxRegion;
237+
}): Promise<MiniMaxOAuthToken> {
238+
ensureGlobalUndiciEnvProxyDispatcher();
239+
const endpoints = getOAuthEndpoints(params.region);
240+
const response = await fetch(endpoints.tokenEndpoint, {
241+
method: "POST",
242+
headers: {
243+
"Content-Type": "application/x-www-form-urlencoded",
244+
Accept: "application/json",
245+
},
246+
body: toFormUrlEncoded({
247+
grant_type: "refresh_token",
248+
client_id: endpoints.clientId,
249+
refresh_token: params.refreshToken,
250+
}),
251+
});
252+
if (!response.ok) {
253+
const text = await response.text();
254+
throw new Error(`MiniMax OAuth refresh failed: ${text || response.statusText}`);
255+
}
256+
const payload = (await response.json()) as {
257+
status?: string;
258+
access_token?: string | null;
259+
refresh_token?: string | null;
260+
expired_in?: number | null;
261+
resource_url?: string;
262+
};
263+
if (payload.status !== "success" || !payload.access_token || !payload.expired_in) {
264+
throw new Error("MiniMax OAuth refresh returned incomplete or unsuccessful payload.");
265+
}
266+
return {
267+
access: payload.access_token,
268+
refresh: payload.refresh_token ?? params.refreshToken,
269+
expires: payload.expired_in,
270+
resourceUrl: payload.resource_url,
271+
};
272+
}

extensions/minimax/provider-registration.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
MINIMAX_OAUTH_MARKER,
1111
ensureAuthProfileStore,
1212
listProfilesForProvider,
13+
type OAuthCredential,
1314
} from "openclaw/plugin-sdk/provider-auth";
1415
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
1516
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
@@ -150,6 +151,9 @@ function createOAuthHandler(region: MiniMaxRegion) {
150151
access: result.access,
151152
refresh: result.refresh,
152153
expires: result.expires,
154+
// Persist enterpriseUrl on the credential so the refresh callback
155+
// can derive which region's OAuth backend to hit (global vs CN).
156+
credentialExtra: { enterpriseUrl: baseUrl },
153157
configPatch: {
154158
models: {
155159
providers: {
@@ -190,6 +194,37 @@ function createOAuthHandler(region: MiniMaxRegion) {
190194
};
191195
}
192196

197+
/**
198+
* Refresh a stored MiniMax OAuth credential. Region is derived from the
199+
* persisted enterpriseUrl (set during login) — `*.minimaxi.com` ⇒ CN,
200+
* everything else ⇒ global.
201+
*/
202+
async function refreshMiniMaxPortalOAuthCredential(
203+
cred: OAuthCredential,
204+
): Promise<OAuthCredential> {
205+
if (!cred.refresh) {
206+
throw new Error("MiniMax OAuth credential has no refresh_token; please re-login.");
207+
}
208+
const region: MiniMaxRegion =
209+
typeof cred.enterpriseUrl === "string" && cred.enterpriseUrl.includes("minimaxi.com")
210+
? "cn"
211+
: "global";
212+
const { refreshMiniMaxPortalOAuth } = await import("./oauth.runtime.js");
213+
const refreshed = await refreshMiniMaxPortalOAuth({
214+
refreshToken: cred.refresh,
215+
region,
216+
});
217+
return {
218+
...cred,
219+
type: "oauth",
220+
provider: PORTAL_PROVIDER_ID,
221+
access: refreshed.access,
222+
refresh: refreshed.refresh,
223+
expires: refreshed.expires,
224+
enterpriseUrl: refreshed.resourceUrl ?? cred.enterpriseUrl,
225+
};
226+
}
227+
193228
function createMinimaxApiKeyMethod(region: MiniMaxRegion) {
194229
const regionLabel = resolveMinimaxRegionLabel(region);
195230
const endpointHint = resolveMinimaxEndpointHint(region);
@@ -279,6 +314,7 @@ export function registerMinimaxProviders(api: OpenClawPluginApi) {
279314
run: async (ctx) => resolvePortalCatalog(ctx),
280315
},
281316
auth: [createMinimaxOAuthMethod("global"), createMinimaxOAuthMethod("cn")],
317+
refreshOAuth: async (cred) => await refreshMiniMaxPortalOAuthCredential(cred),
282318
...MINIMAX_PROVIDER_HOOKS,
283319
isModernModelRef: ({ modelId }) => isMiniMaxModernModelId(modelId),
284320
});

scripts/check-no-raw-channel-fetch.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ const allowedRawFetchCallsites = new Set([
3434
bundledPluginCallsite("microsoft-foundry", "onboard.ts", 479),
3535
bundledPluginCallsite("microsoft", "speech-provider.ts", 140),
3636
bundledPluginCallsite("minimax", "oauth.ts", 66),
37-
bundledPluginCallsite("minimax", "oauth.ts", 107),
37+
bundledPluginCallsite("minimax", "oauth.ts", 106),
38+
bundledPluginCallsite("minimax", "oauth.ts", 240),
3839
bundledPluginCallsite("minimax", "tts.ts", 52),
3940
bundledPluginCallsite("msteams", "src/graph.ts", 47),
4041
bundledPluginCallsite("msteams", "src/sdk.ts", 400),

0 commit comments

Comments
 (0)