fix: chunk OAuth credentials for platform keyrings

This commit is contained in:
2026-08-20 17:27:29 +08:00
parent ac4480bb09
commit af7f184f45
7 changed files with 269 additions and 26 deletions
Vendored
+86 -11
View File
@@ -737,7 +737,7 @@ async function serveBridge(options, tokenStore, discoveryStore, serviceToken = p
}
}
function createProxyServer(upstream) {
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.1" }, {
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.2" }, {
capabilities: {
tools: {},
resources: {}
@@ -752,44 +752,119 @@ function createProxyServer(upstream) {
}
// src/credentials.ts
import { randomUUID as randomUUID2 } from "node:crypto";
var KEYRING_SERVICE = "CraftTable MCP";
var KEYRING_CHUNK_SIZE = 1e3;
var KeyringTokenStore = class {
constructor(entryFactory = keyringEntry) {
this.entryFactory = entryFactory;
}
entryFactory;
async get(account) {
let serialized;
try {
const entry = await keyringEntry(account);
serialized = await entry.getPassword();
serialized = await this.readPassword(account);
} catch (error) {
throw keyringError(error);
}
if (!serialized) return void 0;
const manifest = parseChunkManifest(serialized);
if (manifest) {
try {
const chunks = await Promise.all(Array.from({ length: manifest.chunks }, (_, index) => this.readPassword(chunkAccount(account, manifest.generation, index))));
if (chunks.some((chunk) => chunk === void 0)) throw new Error("missing credential chunk");
serialized = Buffer.from(chunks.join(""), "base64").toString("utf8");
} catch (error) {
if (error instanceof Error && error.message === "missing credential chunk") throw invalidCredentialError();
throw keyringError(error);
}
}
try {
const value = JSON.parse(serialized);
if (!value.access_token || !value.token_type) throw new Error("missing token fields");
return value;
} catch {
throw new Error("The saved CraftTable MCP credential is invalid; run `crafttable-mcp logout --local-only` and log in again");
throw invalidCredentialError();
}
}
async set(account, tokens) {
const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64");
const chunks = splitCredential(encoded);
const generation = randomUUID2().replaceAll("-", "");
const manifest = { version: 2, generation, chunks: chunks.length };
let previous;
const writtenAccounts = [];
try {
const entry = await keyringEntry(account);
await entry.setPassword(JSON.stringify(tokens));
previous = await this.readPassword(account);
for (const [index, chunk] of chunks.entries()) {
const partAccount = chunkAccount(account, generation, index);
await (await this.entryFactory(partAccount)).setPassword(chunk);
writtenAccounts.push(partAccount);
}
await (await this.entryFactory(account)).setPassword(JSON.stringify(manifest));
} catch (error) {
await Promise.allSettled(writtenAccounts.map((partAccount) => this.deletePassword(partAccount)));
throw keyringError(error);
}
const previousManifest = previous && parseChunkManifest(previous);
if (previousManifest) {
await Promise.allSettled(Array.from({ length: previousManifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, previousManifest.generation, index))));
}
}
async delete(account) {
try {
const entry = await keyringEntry(account);
await entry.deleteCredential();
const serialized = await this.readPassword(account);
const manifest = serialized ? parseChunkManifest(serialized) : void 0;
await this.deletePassword(account);
if (manifest) {
await Promise.all(Array.from({ length: manifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, manifest.generation, index))));
}
} catch (error) {
const message = String(error?.message ?? error).toLowerCase();
if (message.includes("no entry") || message.includes("not found")) return;
throw keyringError(error);
}
}
async readPassword(account) {
try {
return await (await this.entryFactory(account)).getPassword();
} catch (error) {
if (isMissingCredential(error)) return void 0;
throw error;
}
}
async deletePassword(account) {
try {
await (await this.entryFactory(account)).deleteCredential();
} catch (error) {
if (!isMissingCredential(error)) throw error;
}
}
};
function splitCredential(value) {
const chunks = [];
for (let offset = 0; offset < value.length; offset += KEYRING_CHUNK_SIZE) {
chunks.push(value.slice(offset, offset + KEYRING_CHUNK_SIZE));
}
return chunks.length ? chunks : [""];
}
function chunkAccount(account, generation, index) {
return `${account}:v2:${generation}:${index}`;
}
function parseChunkManifest(value) {
try {
const parsed = JSON.parse(value);
if (parsed.version !== 2 || typeof parsed.generation !== "string" || !/^[0-9a-f]{32}$/.test(parsed.generation) || !Number.isInteger(parsed.chunks) || (parsed.chunks ?? 0) < 1 || (parsed.chunks ?? 0) > 100) return void 0;
return parsed;
} catch {
return void 0;
}
}
function isMissingCredential(error) {
const message = String(error?.message ?? error).toLowerCase();
return message.includes("no entry") || message.includes("not found");
}
function invalidCredentialError() {
return new Error("The saved CraftTable MCP credential is invalid; run `crafttable-mcp logout --local-only` and log in again");
}
async function keyringEntry(account) {
try {
const { AsyncEntry } = await import("@napi-rs/keyring");
@@ -805,7 +880,7 @@ function keyringError(error) {
}
// src/cli.ts
var program = new Command().name("crafttable-mcp").description("OAuth client and stdio bridge for CraftTable MCP").version("0.1.1");
var program = new Command().name("crafttable-mcp").description("OAuth client and stdio bridge for CraftTable MCP").version("0.1.2");
withConnection(program.command("login").description("Log in through the system browser")).option("--no-browser", "print the authorization URL instead of opening it").option("--timeout <milliseconds>", "OAuth callback timeout", "600000").action(async (flags) => {
const options = connectionOptions(flags);
const timeoutMs = positiveInteger(flags.timeout, "OAuth callback timeout");