feat(xiaomi-mimo): merge MiMo Desktop support into xiaomi-mimo as dual auth
Adds the Desktop-exclusive Preview models and the Xiaomi account-session
route to the existing xiaomi-mimo provider instead of a separate
xiaomi-desktop provider, so the dashboard shows one MiMo entry rather than
three overlapping ones.
Dual auth, same pattern as kimi — API key (sk-) covers the cloud API,
Desktop/OAuth adds the account session used by the Preview models:
- registry: category oauth, authModes [oauth, apikey], oauth block, the two
mimo-x-*-preview models, and the invite signupUrl
- executor: routes Preview models to the account-service route with a Cookie
session, everything else keeps the sourceFormat-matched transport
- oauth: custom ECDH encrypted-callback flow (X25519 -> SHA256 -> AES-256-GCM)
with a loopback callback proxy, plus one-click import of the local Desktop
auth.json
- usage: weekly quota from the account session
Fixes found while merging:
- the OAuth browser flow was dead: poll-status cleared the session before the
client could POST /exchange, so every exchange returned 400
- a Claude-format client was sent to /v1/chat/completions instead of the
declared /anthropic/v1/messages transport, because buildUrl ignored
runtimeTransport
- stopXiaomiMimoProxy leaked every pending session (each holding an X25519
private key) for the process lifetime
- the OAuth exchange did not persist the Desktop passToken, so the Preview
models could never work after a browser sign-in
Removes dead code: the local engine token minting (mimoEngine, never called
on the request path), the model-catalog and usage routes, engineToken/
engineUrl plumbing, and an unread top-level usage block.
Adds tests/unit/xiaomi-mimo-{executor,oauth-session,oauth-proxy}.test.js —
the provider previously had none.
This commit is contained in:
@@ -19,6 +19,10 @@ async function tryBunSqlite() {
|
||||
async function tryBetterSqlite() {
|
||||
// Skip on Bun — better-sqlite3 native bindings unsupported
|
||||
if (process.versions.bun) return null;
|
||||
// Skip on Node >= 24: the native addon SIGSEGVs on load there, which is a
|
||||
// process-level crash the try/catch below cannot recover from. node:sqlite covers it.
|
||||
const [nodeMajor] = process.versions.node.split(".").map(Number);
|
||||
if (nodeMajor >= 24) return null;
|
||||
try {
|
||||
const { createBetterSqliteAdapter } = await import("./adapters/betterSqliteAdapter.js");
|
||||
return createBetterSqliteAdapter(DATA_FILE);
|
||||
|
||||
@@ -130,6 +130,21 @@ export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] };
|
||||
// 3) Redirect → ${cb}?refreshToken=...&loginHost=...&isRedirect=true
|
||||
// 4) POST ExchangeToken {ClientID, RefreshToken, ClientSecret:"-"} → {Result.AccessToken, ExpiresAt}
|
||||
// 5) POST GetUserInfo (x-cloudide-token) → email/name
|
||||
// Xiaomi MiMo Desktop OAuth — custom ECDH encrypted-callback flow (NOT standard OAuth2).
|
||||
// 1) Client generates X25519 keypair
|
||||
// 2) Browser opens ${platformUrl}/authorize?pk=<pubkey>&redirect_uri=http://localhost:<port>/&kn=mimocode&key_name=...
|
||||
// 3) Redirect → http://localhost:<port>/?u=<base64 encrypted payload>
|
||||
// 4) Decrypt: ECDH(shared) → SHA256 → AES-256-GCM
|
||||
// Layout: [12-byte nonce][32-byte ephemeral pubkey][ciphertext][16-byte GCM tag]
|
||||
// 5) Result JSON: { uid, sk, url }
|
||||
export const XIAOMI_MIMO_CONFIG = {
|
||||
platformUrl: process.env.MIMO_PLATFORM_URL || "https://platform.xiaomimimo.com",
|
||||
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
|
||||
kn: "mimocode",
|
||||
callbackPath: "/",
|
||||
timeoutMs: 300000, // 5 minutes
|
||||
};
|
||||
|
||||
export const TRAE_CONFIG = {
|
||||
clientId: "ono9krqynydwx5",
|
||||
clientSecret: "-",
|
||||
|
||||
123
src/lib/oauth/providers/xiaomi-mimo.js
Normal file
123
src/lib/oauth/providers/xiaomi-mimo.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import crypto from "crypto";
|
||||
import { XIAOMI_MIMO_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Xiaomi MiMo OAuth helpers
|
||||
// Custom ECDH + AES-256-GCM encrypted-callback flow (NOT standard OAuth2).
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate an X25519 keypair for the OAuth handshake.
|
||||
* @returns {{ publicKey: string, privateKeyDer: Buffer }}
|
||||
* publicKey — base64 SPKI (for the `pk` URL param)
|
||||
* privateKeyDer — PKCS8 DER Buffer (for ECDH later)
|
||||
*/
|
||||
export function generateKeyPair() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
|
||||
|
||||
const publicKeyDer = publicKey.export({ format: "der", type: "spki" });
|
||||
// SPKI for X25519 is 44 bytes; the raw 32-byte key is the last 32 bytes.
|
||||
// But the platform expects the full base64 SPKI — pass as-is.
|
||||
const publicKeyB64 = publicKeyDer.toString("base64");
|
||||
|
||||
const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" });
|
||||
|
||||
return { publicKey: publicKeyB64, privateKeyDer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the `u` query parameter from the Xiaomi OAuth callback.
|
||||
*
|
||||
* Wire format (base64-decoded):
|
||||
* bytes 0..11 — 12-byte AES-GCM nonce
|
||||
* bytes 12..43 — 32-byte ephemeral public key (raw X25519)
|
||||
* bytes 44..n-16 — ciphertext
|
||||
* last 16 bytes — GCM auth tag
|
||||
*
|
||||
* Key derivation: SHA256(ECDH(clientPrivateKey, ephemeralPublicKey))
|
||||
*
|
||||
* @param {Buffer} privateKeyDer — PKCS8 DER private key from generateKeyPair()
|
||||
* @param {string} encryptedB64 — the `u` query param value (base64)
|
||||
* @returns {{ uid: string, sk: string, url?: string }}
|
||||
*/
|
||||
export function decryptCallback(privateKeyDer, encryptedB64) {
|
||||
const raw = Buffer.from(encryptedB64, "base64");
|
||||
|
||||
if (raw.length < 12 + 32 + 16 + 1) {
|
||||
throw new Error(`Encrypted payload too short: ${raw.length} bytes`);
|
||||
}
|
||||
|
||||
const nonce = raw.subarray(0, 12);
|
||||
const ephemeralPubRaw = raw.subarray(12, 44);
|
||||
const ciphertextAndTag = raw.subarray(44);
|
||||
const tag = ciphertextAndTag.subarray(ciphertextAndTag.length - 16);
|
||||
const ciphertext = ciphertextAndTag.subarray(0, ciphertextAndTag.length - 16);
|
||||
|
||||
// Reconstruct the ephemeral public key as SPKI DER for Node crypto.
|
||||
// X25519 SPKI prefix: 302a300506032b656e032100
|
||||
const ephemeralPub = crypto.createPublicKey({
|
||||
key: Buffer.concat([
|
||||
Buffer.from("302a300506032b656e032100", "hex"),
|
||||
ephemeralPubRaw,
|
||||
]),
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
|
||||
const privateKey = crypto.createPrivateKey({
|
||||
key: privateKeyDer,
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
});
|
||||
|
||||
const sharedSecret = crypto.diffieHellman({ privateKey, publicKey: ephemeralPub });
|
||||
const derivedKey = crypto.createHash("sha256").update(sharedSecret).digest();
|
||||
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", derivedKey, nonce);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
|
||||
const parsed = JSON.parse(decrypted.toString("utf-8"));
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Decrypted payload is not a valid object");
|
||||
}
|
||||
|
||||
return {
|
||||
uid: parsed.uid || null,
|
||||
sk: parsed.sk || null,
|
||||
url: parsed.url || XIAOMI_MIMO_CONFIG.defaultBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the browser authorization URL.
|
||||
* @param {string} publicKey — base64 SPKI from generateKeyPair()
|
||||
* @param {string} redirectUri — e.g. http://localhost:12345/
|
||||
* @param {string} [keyName] — optional stable key name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildAuthorizeUrl(publicKey, redirectUri, keyName) {
|
||||
const params = new URLSearchParams({
|
||||
pk: publicKey,
|
||||
redirect_uri: redirectUri,
|
||||
kn: XIAOMI_MIMO_CONFIG.kn,
|
||||
});
|
||||
if (keyName) params.set("key_name", keyName);
|
||||
return `${XIAOMI_MIMO_CONFIG.platformUrl}/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a stable key name for this installation.
|
||||
* Stored in the 9Router data dir so re-auth reuses the same name.
|
||||
*/
|
||||
export function getKeyName() {
|
||||
// Use a deterministic name based on machine — avoids needing filesystem writes
|
||||
// in the OAuth provider layer. The platform treats key_name as a label only.
|
||||
const machineId = crypto
|
||||
.createHash("sha256")
|
||||
.update(`${process.platform}-${process.env.COMPUTERNAME || process.env.HOSTNAME || "unknown"}`)
|
||||
.digest("hex")
|
||||
.slice(0, 8);
|
||||
return `9router-xmd-${machineId}`;
|
||||
}
|
||||
@@ -755,3 +755,185 @@ export function stopZedProxy() {
|
||||
zedProxyPort = null;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Xiaomi MiMo Desktop OAuth callback proxy
|
||||
// Receives the ECDH-encrypted `u` param, decrypts it, stores the session.
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
let xiaomiMimoProxyServer = null;
|
||||
let xiaomiMimoProxyPort = null;
|
||||
let xiaomiMimoProxyTimeout = null;
|
||||
|
||||
const xiaomiMimoSessions = new Map();
|
||||
|
||||
export function registerXiaomiMimoSession({ state, privateKeyDer }) {
|
||||
if (!state || !privateKeyDer) return false;
|
||||
xiaomiMimoSessions.set(state, {
|
||||
privateKeyDer,
|
||||
status: "pending",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getXiaomiMimoSessionStatus(state) {
|
||||
const s = xiaomiMimoSessions.get(state);
|
||||
if (!s) return null;
|
||||
// Don't leak the private key to the client
|
||||
return { status: s.status, result: s.result || null, error: s.error || null };
|
||||
}
|
||||
|
||||
export function clearXiaomiMimoSession(state) {
|
||||
xiaomiMimoSessions.delete(state);
|
||||
}
|
||||
|
||||
function renderXiaomiMimoResultPage(success, message) {
|
||||
const color = success ? "#22c55e" : "#ef4444";
|
||||
const icon = success ? "✓" : "✗";
|
||||
const title = success ? "Authentication Successful" : "Authentication Failed";
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>${title}</title>
|
||||
<style>
|
||||
body { font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
.icon { color: ${color}; font-size: 3rem; }
|
||||
h1 { margin: 1rem 0; font-size: 1.25rem; }
|
||||
p { color: #666; font-size: 0.875rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">${icon}</div>
|
||||
<h1>${title}</h1>
|
||||
<p>${message || (success ? "You can close this tab and return to 9Router." : "Please try again.")}</p>
|
||||
${success ? "<script>setTimeout(() => window.close(), 3000);</script>" : ""}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Xiaomi Desktop OAuth callback proxy.
|
||||
* @returns {Promise<{success: boolean, port?: number, callbackUrl?: string, reason?: string}>}
|
||||
*/
|
||||
export function startXiaomiMimoProxy() {
|
||||
return new Promise((resolve) => {
|
||||
if (xiaomiMimoProxyServer) {
|
||||
resolve({
|
||||
success: true,
|
||||
port: xiaomiMimoProxyPort,
|
||||
callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// Origin guard
|
||||
if (!isLoopbackOrigin(req.headers.origin)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
const u = url.searchParams.get("u");
|
||||
|
||||
if (!u) {
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(renderXiaomiMimoResultPage(false, "Missing encrypted payload (u parameter)."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Try each pending session's private key — the callback URL carries no
|
||||
// state param, so we attempt decryption with every pending key.
|
||||
const pendingSessions = [...xiaomiMimoSessions.entries()]
|
||||
.filter(([, s]) => s.status === "pending");
|
||||
|
||||
if (pendingSessions.length === 0) {
|
||||
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(renderXiaomiMimoResultPage(false, "No active OAuth session. Please restart the login flow."));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { decryptCallback } = await import("../providers/xiaomi-mimo.js");
|
||||
let result = null;
|
||||
let matchedState = null;
|
||||
|
||||
for (const [state, session] of pendingSessions) {
|
||||
try {
|
||||
result = decryptCallback(session.privateKeyDer, u);
|
||||
matchedState = state;
|
||||
break;
|
||||
} catch {
|
||||
// Wrong key for this session — try next
|
||||
}
|
||||
}
|
||||
|
||||
if (!result || !matchedState) {
|
||||
throw new Error("Could not decrypt with any pending session key");
|
||||
}
|
||||
|
||||
if (!result.sk) {
|
||||
throw new Error("Decrypted payload missing sk (API key)");
|
||||
}
|
||||
|
||||
// Store result only in the matched session
|
||||
const session = xiaomiMimoSessions.get(matchedState);
|
||||
if (session) {
|
||||
session.status = "done";
|
||||
session.result = {
|
||||
uid: result.uid,
|
||||
accessToken: result.sk,
|
||||
baseUrl: result.url || "https://api.xiaomimimo.com/v1",
|
||||
};
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(renderXiaomiMimoResultPage(true, "Xiaomi account linked. You can close this tab."));
|
||||
console.log("[xiaomi-mimo oauth] callback decrypted, uid:", result.uid);
|
||||
} catch (err) {
|
||||
console.error("[xiaomi-mimo oauth] decrypt failed:", err.message);
|
||||
for (const [, session] of pendingSessions) {
|
||||
session.status = "error";
|
||||
session.error = err.message;
|
||||
}
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(renderXiaomiMimoResultPage(false, `Decryption failed: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
console.log("[xiaomi-mimo oauth] listen error:", err.message);
|
||||
resolve({ success: false, reason: err.message });
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
xiaomiMimoProxyServer = server;
|
||||
xiaomiMimoProxyPort = server.address().port;
|
||||
xiaomiMimoProxyTimeout = setTimeout(() => {
|
||||
console.log("[xiaomi-mimo oauth] timeout, stopping");
|
||||
stopXiaomiMimoProxy();
|
||||
}, 300000);
|
||||
console.log(`[xiaomi-mimo oauth] listening on port ${xiaomiMimoProxyPort}`);
|
||||
resolve({
|
||||
success: true,
|
||||
port: xiaomiMimoProxyPort,
|
||||
callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function stopXiaomiMimoProxy() {
|
||||
console.log(`[xiaomi-mimo oauth] stopping (port ${xiaomiMimoProxyPort || "-"})`);
|
||||
if (xiaomiMimoProxyTimeout) { clearTimeout(xiaomiMimoProxyTimeout); xiaomiMimoProxyTimeout = null; }
|
||||
if (xiaomiMimoProxyServer) { xiaomiMimoProxyServer.close(); xiaomiMimoProxyServer = null; }
|
||||
xiaomiMimoProxyPort = null;
|
||||
// No callback can arrive once the listener is down, so drop every pending
|
||||
// session — each holds an X25519 private key and they would otherwise
|
||||
// accumulate for the process lifetime (one per /authorize call).
|
||||
xiaomiMimoSessions.clear();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user