feat(xiaomi-mimo): server-assisted desktop login, five account clusters, v2.6 models

Reproduce the MiMo Desktop login surface server-side so headless/Docker
deployments can link a Xiaomi account without the Desktop client. The
account session (passToken) is captured during the proxied login and
stored per connection.

- Five account clusters (cn/sgp/ams/ru/in): per-region mimo-server host
  and SSO sid, unknown region falls back to sgp
- mimo-v2.6-pro/flash/pro-ultraspeed dual-route models: account-service
  route when desktop credentials exist, cloud API (sk- key) otherwise;
  drops obsolete mimo-x-*-preview ids
- Desktop ServiceTokenManager 2-phase handshake (single serviceLogin with
  target sid, raw 64-bit nonce preserved), per-region session cache
- reasoning_effort bridged to output_config.effort; i18n runtime now
  observes characterData mutations so React text rewrites get translated
- Security hardening on the login proxy: session travels only in the
  httpOnly cookie (never in the URL), proxy branch requires dashboard
  auth, authorization/proxy-authorization never forwarded upstream, and
  upstream Set-Cookie is not replayed onto the app origin
This commit is contained in:
wismyzhizi
2026-09-23 09:43:54 +07:00
committed by decolua
parent 6af26a9ee8
commit 910db749aa
16 changed files with 1687 additions and 352 deletions
+129 -62
View File
@@ -8,20 +8,42 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js";
* Xiaomi MiMo account-session helpers (used for weekly quota).
*
* The weekly quota endpoint lives on the account service domain and is authorized
* by an account session cookie, NOT the sk- API key. Acquiring that cookie mirrors
* MiMo Desktop: a passToken (persisted in Desktop's cookie store) is exchanged via
* the passportapi SSO, then authorized for the `mimopc` service, and finally stamped
* by the mimo-server /api/sts callback into a `serviceToken` cookie.
* by an account session cookie, NOT the sk- API key. Acquiring that cookie is a
* 1:1 port of MiMo Desktop's ServiceTokenManager (app.asar) — the GOLD STANDARD:
*
* Flow (verified against MiMo Desktop traffic):
* 1. GET {api}/api/user/xiaomi/me -> 302 to account SSO (sid=mimopc)
* 2. GET account /pass/serviceLogin?sid=passportapi&_json=true -> nonce/ssecurity
* 3. GET {location}&clientSign=... -> account-level serviceToken
* 4. GET account /pass/serviceLogin?sid=mimopc&callback=<sts>&_json=true
* 5. GET {api}/api/sts?...&ticket... -> Set-Cookie: serviceToken (mimopc scope)
* getServiceToken(sid) / refreshServiceToken(sid):
* PHASE 1: GET https://account.xiaomi.com/pass/serviceLogin
* ?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
* Cookie: {userId, passToken, cUserId}
* -> {code, location, ssecurity, nonce, bSecondValidation, notificationUrl}
* -> code !== 0 is an error (never silent)
* PHASE 2: GET {location}&clientSign=sha1(nonce & ssecurity), follow the
* redirect chain absorbing Set-Cookie -> serviceToken
*
* sid is per-cluster (SID_BY_REGION): CN = mimopc, SGP = mimosgp.
*/
const API_BASE = "https://mimo-server-cn.xiaomimimo.com";
// Account-service cluster hosts. MiMo Desktop declares five regions
// (rn = {CN, SGP, RU, IN, EU}); the EU cluster is deployed in Amsterdam.
// Host + sid naming is unified: mimo-server-<code> / sid = mimo<code>
// (ams is the only non-country code). Verified live via /api/user/xiaomi/me.
const API_BASE_BY_REGION = {
cn: "https://mimo-server-cn.xiaomimimo.com",
sgp: "https://mimo-server-sgp.xiaomimimo.com",
ams: "https://mimo-server-ams.xiaomimimo.com",
ru: "https://mimo-server-ru.xiaomimimo.com",
in: "https://mimo-server-in.xiaomimimo.com",
};
const DEFAULT_API_BASE = API_BASE_BY_REGION.sgp;
// Cluster service sid — 1:1 with the host code: mimo<code>.
// Unknown/absent region falls back to SGP (the international/open cluster).
const SID_BY_REGION = { cn: "mimopc", sgp: "mimosgp", ams: "mimoams", ru: "mimoru", in: "mimoin" };
function sidForRegion(region) {
const r = String(region || "").toLowerCase();
return SID_BY_REGION[r] || SID_BY_REGION.sgp;
}
const API_BASE = DEFAULT_API_BASE;
const ACCOUNT_HOST = "account.xiaomi.com";
const API_UA =
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
@@ -112,65 +134,106 @@ function cookieHeader(jar) {
.join("; ");
}
/**
* Resolve the account-service base URL for a connection.
* @param {object|null} providerSpecificData - may carry `region` ("cn"|"sgp"|"ams"|"ru"|"in")
*/
export function resolveMimoServerBase(providerSpecificData = null) {
const region = String(providerSpecificData?.region || "").toLowerCase();
return API_BASE_BY_REGION[region] || DEFAULT_API_BASE;
}
/**
* Exchange a passToken for a mimo-server service session cookie.
* Primary path mirrors the Desktop ServiceTokenManager (app.asar):
* PHASE 1: GET /pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
* Cookie {userId,passToken,cUserId} -> {code,location,ssecurity,nonce}
* PHASE 2: GET {location}&clientSign=sha1(nonce&ssecurity), follow the chain
* (manual, absorbing Set-Cookie) -> serviceToken
* sid is per-cluster (SID_BY_REGION): cn=mimopc, sgp=mimosgp, ams=mimoams, ru=mimoru, in=mimoin.
* @returns {Promise<string|null>} Cookie header value, or null on failure.
*/
async function acquireServiceCookie(passJar, proxyOptions) {
async function acquireServiceCookie(passJar, proxyOptions, apiBase = DEFAULT_API_BASE, region = "sgp") {
const r = String(region || "").toLowerCase();
// Hard constraint: CN is ALWAYS direct (ignores proxy even if set)
const effectiveProxy = r === "cn" ? null : proxyOptions;
const sid = sidForRegion(r);
const viaDesktop = await acquireViaDesktopPhases(passJar, effectiveProxy, apiBase, sid);
if (viaDesktop) console.log(`[mimoAccount] desktop 2-phase OK (sid=${sid})`);
return viaDesktop;
}
async function acquireViaDesktopPhases(passJar, proxyOptions, apiBase, sid) {
const failLog = (reason) => console.log(`[mimoAccount] desktopPhase fail: ${reason}`);
const jar = { ...passJar };
const ck = () => cookieHeader(jar);
// 1. Unauthenticated API call -> 302 carrying the sts callback (sid=mimopc)
const r1 = await proxyAwareFetch(
`${API_BASE}/api/user/xiaomi/me`,
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
// PHASE 1 — single serviceLogin call with the TARGET sid (no passportapi
// prelude; ssecurity/nonce come straight from this response).
// Desktop only sends: userId, passToken, cUserId (no extra cookies)
const p1Jar = {};
if (jar.userId) p1Jar.userId = jar.userId;
if (jar.passToken) p1Jar.passToken = jar.passToken;
if (jar.cUserId) p1Jar.cUserId = jar.cUserId;
const p1Url = `https://${ACCOUNT_HOST}/pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=${encodeURIComponent(sid)}&_json=true`;
const p1 = await proxyAwareFetch(
p1Url,
{ headers: { Cookie: cookieHeader(p1Jar), "User-Agent": SSO_UA, Accept: "application/json" } },
proxyOptions,
);
const redirect = r1.headers.get("location");
if (!redirect) return null;
const stsCallback = new URL(redirect).searchParams.get("callback");
if (!stsCallback) return null;
const raw = await p1.text();
const clean = raw.replace(/^&&&START&&&/, "");
// Nonce > 2^53 loses precision in JSON.parse — extract raw literal for signing
const rawNonce = clean.match(/"nonce"\s*:\s*(\d+)/)?.[1];
let j = null;
try { j = JSON.parse(clean); } catch { /* handled below */ }
if (rawNonce && j) j.nonce = rawNonce;
// 2. passportapi SSO phase 1 -> nonce + ssecurity
const sso1 = await proxyAwareFetch(
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=passportapi&_json=true`,
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
proxyOptions,
);
const j1 = JSON.parse((await sso1.text()).replace(/^&&&START&&&/, ""));
const nonce = j1.nonce || (j1.location ? new URL(j1.location).searchParams.get("nonce") : null);
if (!nonce || !j1.location) return null;
if (!j || typeof j.code !== "number" || j.code !== 0 || !j.location || !j.nonce || !j.ssecurity) {
failLog(
`phase1 sid=${sid} http=${p1.status} code=${j?.code ?? "?"} hasLoc=${!!j?.location}`
+ ` secondValidation=${j?.bSecondValidation ?? "?"} notificationUrl=${j?.notificationUrl ? "present" : "no"}`
+ ` body=${JSON.stringify(raw.slice(0, 200))}`,
);
return null;
}
absorbSetCookie(jar, p1);
// 3. passportapi SSO phase 2 -> account-level serviceToken
const sso2 = await proxyAwareFetch(
`${j1.location}&clientSign=${signatureClientSign(nonce, j1.ssecurity)}`,
{ redirect: "manual", headers: { Cookie: ck(), "User-Agent": SSO_UA } },
proxyOptions,
);
absorbSetCookie(jar, sso2);
// PHASE 2 — clientSign the redirect, follow the redirect chain server-side.
// ⚠️ CRITICAL DESKTOP SPEC (app.asar / SSO_curl.cpp line 728: cookies.clear()):
// Phase 2 MUST NOT send ANY Cookie header! The server returns 200 OK with Set-Cookie: serviceToken!
const sep = j.location.includes("?") ? "&" : "?";
let current = `${j.location}${sep}clientSign=${signatureClientSign(rawNonce || j.nonce, j.ssecurity)}`;
// 4. mimopc SSO -> sts callback carrying a ticket
const sso3 = await proxyAwareFetch(
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=mimopc&callback=${encodeURIComponent(stsCallback)}&_json=true`,
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
proxyOptions,
);
const j3 = JSON.parse((await sso3.text()).replace(/^&&&START&&&/, ""));
absorbSetCookie(jar, sso3);
if (!j3?.location || !/\/api\/sts/.test(j3.location)) return null;
for (let hop = 0; hop < 8; hop++) {
const res = await proxyAwareFetch(
current,
{ redirect: "manual", headers: { "User-Agent": SSO_UA } },
proxyOptions,
);
absorbSetCookie(jar, res);
const loc = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && loc) {
current = new URL(loc, current).toString();
continue;
}
break;
}
// 5. sts callback -> Set-Cookie: serviceToken (mimopc scope)
const sts = await proxyAwareFetch(
j3.location,
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
proxyOptions,
);
absorbSetCookie(jar, sts);
const sidKey = `${sid}_serviceToken`;
if (!jar.serviceToken && jar[sidKey]) {
jar.serviceToken = jar[sidKey];
}
const needed = ["serviceToken", "mimopc_ph", "mimopc_slh", "userId"];
if (!jar.serviceToken) return null;
if (!jar.serviceToken) {
failLog(`phase2 no serviceToken sid=${sid} jar=[${Object.keys(jar).join(",")}]`);
return null;
}
const out = {};
for (const k of needed) if (jar[k]) out[k] = jar[k];
for (const [k, v] of Object.entries(jar)) {
if (!v) continue;
if (k === "serviceToken" || k === "userId" || /_(ph|slh)$/.test(k)) out[k] = v;
}
return cookieHeader(out);
}
@@ -179,13 +242,15 @@ async function acquireServiceCookie(passJar, proxyOptions) {
* @param {object|null} providerSpecificData - may carry `mimoPassToken` override
*/
async function getServiceCookie(providerSpecificData, proxyOptions) {
const apiBase = resolveMimoServerBase(providerSpecificData);
const passJar = providerSpecificData?.mimoPassToken
? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId }
: await readDesktopAccountCookies();
if (!passJar) return { cookie: null, reason: "no-pass-token" };
// One cached session per passToken — accounts/connections rotate independently.
const key = crypto.createHash("sha256").update(passJar.passToken).digest("hex");
// One cached session per passToken+cluster — accounts/connections rotate
// independently, and the same passToken maps to different sessions per region.
const key = crypto.createHash("sha256").update(`${apiBase}|${passJar.passToken}`).digest("hex");
const cached = _cache.get(key);
if (cached && Date.now() - cached.at < COOKIE_TTL_MS) {
@@ -202,8 +267,9 @@ async function getServiceCookie(providerSpecificData, proxyOptions) {
const promise = (async () => {
try {
return await acquireServiceCookie(passJar, proxyOptions);
} catch {
return await acquireServiceCookie(passJar, proxyOptions, apiBase, providerSpecificData?.region);
} catch (e) {
console.log(`[mimoAccount] acquire threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`);
return null; // network/parse failure — callers degrade, never throw
} finally {
_inflight.delete(key);
@@ -234,7 +300,8 @@ export async function getMimoAccountCookie(providerSpecificData = null, proxyOpt
try {
const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions);
return cookie;
} catch {
} catch (e) {
console.log(`[mimoAccount] getMimoAccountCookie threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`);
return null;
}
}
@@ -250,7 +317,7 @@ export async function getMimoAccountUsage(providerSpecificData = null, proxyOpti
}
try {
const res = await proxyAwareFetch(
`${API_BASE}/api/user/usage`,
`${resolveMimoServerBase(providerSpecificData)}/api/user/usage`,
{ headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) },
proxyOptions,
);