feat: harden install and resumable OAuth login
This commit is contained in:
+404
-45
@@ -1,11 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdtempSync, statSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { NocoBaseAuthService } from "../src/auth-service.js";
|
||||
import { AuthorizationStore, pendingAuthorizationPath } from "../src/authorization-store.js";
|
||||
import { loadStoredCredentials, saveCredentials } from "../src/credentials.js";
|
||||
import type { OAuthCredentials } from "../src/oauth.js";
|
||||
import type { BrowserLoginPollResult, OAuthCredentials, PendingOAuthAuthorization } from "../src/oauth.js";
|
||||
|
||||
const oauthCredentials: OAuthCredentials = {
|
||||
type: "oauth",
|
||||
@@ -18,27 +29,22 @@ const oauthCredentials: OAuthCredentials = {
|
||||
expiresAt: 901_000,
|
||||
};
|
||||
|
||||
test("returns a browser link immediately, then verifies and saves OAuth credentials in the background", async () => {
|
||||
test("returns a browser link, then polls and saves OAuth credentials without exposing secrets", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-service-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
let resolveCredentials: (credentials: OAuthCredentials) => void = () => undefined;
|
||||
const credentials = new Promise<OAuthCredentials>((resolve) => {
|
||||
resolveCredentials = resolve;
|
||||
});
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
let currentTime = 1_000;
|
||||
let polls = 0;
|
||||
let verified = 0;
|
||||
let reloaded = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
startLogin: async () => ({
|
||||
prompt: {
|
||||
opened: false,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
userCode: "ABCD-EFGH",
|
||||
expiresIn: 600,
|
||||
},
|
||||
credentials,
|
||||
}),
|
||||
now: () => currentTime,
|
||||
beginLogin: async () => pendingAuthorization(),
|
||||
pollLogin: async (): Promise<BrowserLoginPollResult> => {
|
||||
polls += 1;
|
||||
return { status: "authenticated", credentials: oauthCredentials };
|
||||
},
|
||||
verifyCredentials: async (value) => {
|
||||
assert.equal(value.accessToken, oauthCredentials.accessToken);
|
||||
verified += 1;
|
||||
@@ -49,50 +55,145 @@ test("returns a browser link immediately, then verifies and saves OAuth credenti
|
||||
});
|
||||
|
||||
const started = await service.start();
|
||||
assert.deepEqual(started, {
|
||||
status: "authorization_pending",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
user_code: "ABCD-EFGH",
|
||||
expires_in_seconds: 600,
|
||||
browser_opened: false,
|
||||
next_action:
|
||||
"Send login_url and user_code to the user. After approval, poll nocobase_login_status until authenticated.",
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(started), /private-access-token|private-refresh-token/);
|
||||
assert.deepEqual(started, pendingPayload());
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(started),
|
||||
/private-access-token|private-refresh-token|private-device-code/,
|
||||
);
|
||||
assert.equal(existsSync(path), false);
|
||||
assert.equal(existsSync(pendingPath), true);
|
||||
if (process.platform !== "win32") assert.equal(statSync(pendingPath).mode & 0o777, 0o600);
|
||||
assert.equal((await service.status()).status, "authorization_pending");
|
||||
assert.equal(polls, 0);
|
||||
|
||||
resolveCredentials(oauthCredentials);
|
||||
for (let attempt = 0; attempt < 10 && !existsSync(path); attempt += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
currentTime = 6_000;
|
||||
assert.equal((await service.status()).status, "authenticated");
|
||||
assert.equal(polls, 1);
|
||||
assert.equal(verified, 1);
|
||||
assert.equal(reloaded, 1);
|
||||
assert.equal((await service.status()).status, "authenticated");
|
||||
assert.equal(existsSync(pendingPath), false);
|
||||
const stored = loadStoredCredentials(path);
|
||||
assert.equal(stored.type, "oauth");
|
||||
if (stored.type === "oauth") {
|
||||
assert.equal(stored.authorizationSessionId, "authorization-session");
|
||||
assert.equal(stored.accessToken, oauthCredentials.accessToken);
|
||||
}
|
||||
if (process.platform !== "win32") assert.equal(statSync(path).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test("reuses stored login unless a fresh authorization is explicitly requested", async () => {
|
||||
test("a new service instance resumes and completes the same pending browser authorization", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-restart-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const first = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
beginLogin: async () => pendingAuthorization(),
|
||||
});
|
||||
const started = await first.start();
|
||||
assert.equal(started.status, "authorization_pending");
|
||||
|
||||
let starts = 0;
|
||||
let polls = 0;
|
||||
const restarted = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 6_000,
|
||||
beginLogin: async () => {
|
||||
starts += 1;
|
||||
return pendingAuthorization();
|
||||
},
|
||||
pollLogin: async () => {
|
||||
polls += 1;
|
||||
return { status: "authenticated", credentials: oauthCredentials };
|
||||
},
|
||||
verifyCredentials: async () => undefined,
|
||||
});
|
||||
const resumed = await restarted.status();
|
||||
assert.equal(resumed.status, "authenticated");
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(polls, 1);
|
||||
assert.doesNotMatch(JSON.stringify(resumed), /private-device-code|private-access-token/);
|
||||
assert.equal(existsSync(pendingAuthorizationPath(path)), false);
|
||||
assert.equal(loadStoredCredentials(path).type, "oauth");
|
||||
});
|
||||
|
||||
test("a new OS process resumes and completes the same pending browser authorization", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-child-restart-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const authServiceUrl = pathToFileURL(resolve("src/auth-service.ts")).href;
|
||||
const first = runNodeModule(`
|
||||
const { NocoBaseAuthService } = await import(${JSON.stringify(authServiceUrl)});
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: ${JSON.stringify(path)},
|
||||
now: () => 1000,
|
||||
beginLogin: async () => (${JSON.stringify(pendingAuthorization())})
|
||||
});
|
||||
process.stdout.write(JSON.stringify(await service.start()));
|
||||
`);
|
||||
assert.equal(JSON.parse(first).status, "authorization_pending");
|
||||
assert.equal(existsSync(pendingAuthorizationPath(path)), true);
|
||||
|
||||
const second = runNodeModule(`
|
||||
const { NocoBaseAuthService } = await import(${JSON.stringify(authServiceUrl)});
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: ${JSON.stringify(path)},
|
||||
now: () => 6000,
|
||||
pollLogin: async () => ({
|
||||
status: "authenticated",
|
||||
credentials: ${JSON.stringify(oauthCredentials)}
|
||||
}),
|
||||
verifyCredentials: async () => undefined
|
||||
});
|
||||
process.stdout.write(JSON.stringify(await service.status()));
|
||||
`);
|
||||
assert.equal(JSON.parse(second).status, "authenticated");
|
||||
assert.equal(existsSync(pendingAuthorizationPath(path)), false);
|
||||
const stored = loadStoredCredentials(path);
|
||||
assert.equal(stored.type, "oauth");
|
||||
if (stored.type === "oauth") {
|
||||
assert.equal(stored.authorizationSessionId, "authorization-session");
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps exchanged credentials when post-login verification is temporarily unavailable", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-verify-failure-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
savePendingAuthorization(pendingAuthorization({ nextPollAt: 1_000 }), pendingPath);
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
pollLogin: async () => ({ status: "authenticated", credentials: oauthCredentials }),
|
||||
verifyCredentials: async () => {
|
||||
throw new Error("temporary health check failure");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.status();
|
||||
assert.equal(result.status, "authenticated");
|
||||
assert.equal(result.credential_verification, "failed");
|
||||
assert.match(String((result.verification_error as Record<string, unknown>).message), /temporary/);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(result),
|
||||
/private-access-token|private-refresh-token|private-device-code/,
|
||||
);
|
||||
assert.equal(existsSync(pendingPath), false);
|
||||
assert.equal(loadStoredCredentials(path).type, "oauth");
|
||||
|
||||
const restarted = new NocoBaseAuthService({ credentialsFile: path });
|
||||
assert.equal((await restarted.status()).status, "authenticated");
|
||||
});
|
||||
|
||||
test("reuses stored login and an already pending forced login", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-force-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
saveCredentials({ authorizeKey: "personal-key" }, path);
|
||||
let starts = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
startLogin: async () => {
|
||||
now: () => 1_000,
|
||||
beginLogin: async () => {
|
||||
starts += 1;
|
||||
return {
|
||||
prompt: {
|
||||
opened: true,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=FRESH-CODE",
|
||||
userCode: "FRESH-CODE",
|
||||
expiresIn: 600,
|
||||
},
|
||||
credentials: new Promise<OAuthCredentials>(() => undefined),
|
||||
};
|
||||
return pendingAuthorization();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,3 +204,261 @@ test("reuses stored login unless a fresh authorization is explicitly requested",
|
||||
assert.equal((await service.start({ force: true })).status, "authorization_pending");
|
||||
assert.equal(starts, 1);
|
||||
});
|
||||
|
||||
test("coalesces concurrent login starts across MCP service instances", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-start-concurrent-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
let starts = 0;
|
||||
let resolveStart: (authorization: PendingOAuthAuthorization) => void = () => undefined;
|
||||
const started = new Promise<PendingOAuthAuthorization>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
const options = {
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
beginLogin: async () => {
|
||||
starts += 1;
|
||||
return await started;
|
||||
},
|
||||
};
|
||||
const firstService = new NocoBaseAuthService(options);
|
||||
const secondService = new NocoBaseAuthService(options);
|
||||
|
||||
const first = firstService.start();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const second = secondService.start();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(starts, 1);
|
||||
resolveStart(pendingAuthorization());
|
||||
const results = await Promise.all([first, second]);
|
||||
assert.deepEqual(
|
||||
results.map((result) => result.login_url),
|
||||
[
|
||||
"https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
"https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
],
|
||||
);
|
||||
assert.equal(starts, 1);
|
||||
});
|
||||
|
||||
test("removes an expired pending authorization instead of creating another link", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-expired-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
savePendingAuthorization(pendingAuthorization({ expiresAt: 999, nextPollAt: 999 }), pendingPath);
|
||||
let polls = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
pollLogin: async () => {
|
||||
polls += 1;
|
||||
return { status: "authorization_pending", authorization: pendingAuthorization() };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.status();
|
||||
assert.equal(result.status, "authorization_failed");
|
||||
assert.equal(polls, 0);
|
||||
assert.equal(existsSync(pendingPath), false);
|
||||
assert.doesNotMatch(JSON.stringify(result), /private-device-code/);
|
||||
});
|
||||
|
||||
test("stored credentials supersede an earlier in-memory authorization failure", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-disk-truth-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
savePendingAuthorization(
|
||||
pendingAuthorization({ expiresAt: 999, nextPollAt: 999 }),
|
||||
pendingAuthorizationPath(path),
|
||||
);
|
||||
const service = new NocoBaseAuthService({ credentialsFile: path, now: () => 1_000 });
|
||||
|
||||
assert.equal((await service.status()).status, "authorization_failed");
|
||||
saveCredentials({ authorizeKey: "new-personal-key" }, path);
|
||||
assert.equal((await service.status()).status, "authenticated");
|
||||
});
|
||||
|
||||
test("coalesces concurrent status polls for one pending authorization", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-concurrent-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
savePendingAuthorization(pendingAuthorization({ nextPollAt: 1_000 }), pendingAuthorizationPath(path));
|
||||
let polls = 0;
|
||||
let resolvePoll: (result: BrowserLoginPollResult) => void = () => undefined;
|
||||
const poll = new Promise<BrowserLoginPollResult>((resolve) => {
|
||||
resolvePoll = resolve;
|
||||
});
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
pollLogin: async () => {
|
||||
polls += 1;
|
||||
return await poll;
|
||||
},
|
||||
verifyCredentials: async () => undefined,
|
||||
});
|
||||
|
||||
const first = service.status();
|
||||
const second = service.status();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(polls, 1);
|
||||
resolvePoll({ status: "authenticated", credentials: oauthCredentials });
|
||||
const results = await Promise.all([first, second]);
|
||||
assert.deepEqual(
|
||||
results.map((result) => result.status),
|
||||
["authenticated", "authenticated"],
|
||||
);
|
||||
});
|
||||
|
||||
test("prevents duplicate status polls across MCP service instances", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-poll-concurrent-"));
|
||||
const path = join(directory, "credentials.json");
|
||||
savePendingAuthorization(pendingAuthorization({ nextPollAt: 1_000 }), pendingAuthorizationPath(path));
|
||||
let polls = 0;
|
||||
let resolvePoll: (result: BrowserLoginPollResult) => void = () => undefined;
|
||||
const poll = new Promise<BrowserLoginPollResult>((resolve) => {
|
||||
resolvePoll = resolve;
|
||||
});
|
||||
const options = {
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
pollLogin: async () => {
|
||||
polls += 1;
|
||||
return await poll;
|
||||
},
|
||||
verifyCredentials: async () => undefined,
|
||||
};
|
||||
const firstService = new NocoBaseAuthService(options);
|
||||
const secondService = new NocoBaseAuthService(options);
|
||||
|
||||
const first = firstService.status();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const second = await secondService.status();
|
||||
assert.equal(polls, 1);
|
||||
assert.equal(second.status, "authorization_pending");
|
||||
assert.equal(second.poll_in_progress, true);
|
||||
assert.doesNotMatch(JSON.stringify(second), /private-device-code|private-access-token/);
|
||||
|
||||
resolvePoll({ status: "authenticated", credentials: oauthCredentials });
|
||||
assert.equal((await first).status, "authenticated");
|
||||
assert.equal((await secondService.status()).status, "authenticated");
|
||||
assert.equal(polls, 1);
|
||||
});
|
||||
|
||||
test("reclaims an authorization lock owned by a dead process", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-dead-lock-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
const lockPath = `${pendingPath}.lock`;
|
||||
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(lockPath, JSON.stringify({ pid: 2_147_483_647, createdAt: 1_000, token: "dead-owner" }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
let starts = 0;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 2_000,
|
||||
beginLogin: async () => {
|
||||
starts += 1;
|
||||
return pendingAuthorization();
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await service.start()).status, "authorization_pending");
|
||||
assert.equal(starts, 1);
|
||||
assert.equal(existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
test("reclaims a malformed authorization lock only after it becomes stale", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-malformed-lock-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const pendingPath = pendingAuthorizationPath(path);
|
||||
const lockPath = `${pendingPath}.lock`;
|
||||
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(lockPath, "not-json", { mode: 0o600 });
|
||||
utimesSync(lockPath, new Date(0), new Date(0));
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 200_000,
|
||||
beginLogin: async () => pendingAuthorization(),
|
||||
});
|
||||
|
||||
assert.equal((await service.start()).status, "authorization_pending");
|
||||
assert.equal(existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
test("a lock holder never removes a replacement lock owned by another process", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-auth-lock-owner-"));
|
||||
const path = join(directory, "private", "credentials.json");
|
||||
const lockPath = `${pendingAuthorizationPath(path)}.lock`;
|
||||
const service = new NocoBaseAuthService({
|
||||
credentialsFile: path,
|
||||
now: () => 1_000,
|
||||
beginLogin: async () => {
|
||||
writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({ pid: process.pid, createdAt: 1_000, token: "replacement-owner" }),
|
||||
);
|
||||
return pendingAuthorization();
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await service.start()).status, "authorization_pending");
|
||||
assert.equal(JSON.parse(readFileSync(lockPath, "utf8")).token, "replacement-owner");
|
||||
});
|
||||
|
||||
function pendingAuthorization(overrides: Partial<PendingOAuthAuthorization> = {}): PendingOAuthAuthorization {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: "oauth-device-authorization",
|
||||
sessionId: "authorization-session",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "desktop-client",
|
||||
deviceCode: "private-device-code",
|
||||
prompt: {
|
||||
opened: false,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
userCode: "ABCD-EFGH",
|
||||
expiresIn: 600,
|
||||
},
|
||||
expiresAt: 601_000,
|
||||
intervalSeconds: 5,
|
||||
nextPollAt: 6_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingPayload(): Record<string, unknown> {
|
||||
return {
|
||||
status: "authorization_pending",
|
||||
authorization_type: "oauth_device_user_verification",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
|
||||
user_code: "ABCD-EFGH",
|
||||
intended_recipient: "requesting_user",
|
||||
delivery_channel: "current_private_conversation",
|
||||
shareable_with_requesting_user: true,
|
||||
private_conversation_required: true,
|
||||
public_sharing_allowed: false,
|
||||
contains_bearer_credentials: false,
|
||||
contains_device_code: false,
|
||||
remote_desktop_required: false,
|
||||
expires_in_seconds: 600,
|
||||
retry_after_seconds: 5,
|
||||
browser_opened: false,
|
||||
next_action:
|
||||
"Deliver login_url and user_code verbatim to the requesting user once. On a headless or remote server, send them in the current private conversation: they are OAuth device user-verification data, not a password, API key, bearer token, or device_code, and remote desktop is not required. Never post them publicly or send them to a third party. Then wait retry_after_seconds and call nocobase_login_status. Reuse this authorization; do not ask the user to log in again.",
|
||||
};
|
||||
}
|
||||
|
||||
function runNodeModule(source: string): string {
|
||||
const child = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", source], {
|
||||
cwd: resolve("."),
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
assert.equal(child.status, 0, child.stderr || child.error?.message);
|
||||
return child.stdout;
|
||||
}
|
||||
|
||||
function savePendingAuthorization(authorization: PendingOAuthAuthorization, path: string): void {
|
||||
new AuthorizationStore(path).save(authorization);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { clientConfig, runCli } from "../src/cli.js";
|
||||
import { clientConfig, runCli, setupAuthorizationEvent } from "../src/cli.js";
|
||||
import { VERSION } from "../src/version.js";
|
||||
|
||||
test("package, manifest, and runtime versions agree with commit-pinned installation", () => {
|
||||
@@ -19,33 +19,115 @@ test("package, manifest, and runtime versions agree with commit-pinned installat
|
||||
assert.equal(manifest.distribution.releaseRef, undefined);
|
||||
assert.equal(manifest.installation.temporarySourceArchiveBuild, true);
|
||||
assert.equal(manifest.installation.fallbackToRelease, false);
|
||||
assert.equal(manifest.installation.supportsInPlaceUpdate, true);
|
||||
assert.equal(manifest.installation.sameCommitBuildSkip, true);
|
||||
assert.deepEqual(manifest.installation.installReceipt, {
|
||||
path: "<install-prefix>/install-receipt.json",
|
||||
schemaVersion: 1,
|
||||
posixFileMode: "0600",
|
||||
containsSecrets: false,
|
||||
});
|
||||
assert.deepEqual(manifest.installation.operationDetection, [
|
||||
"fresh-install",
|
||||
"in-place-update",
|
||||
"repair",
|
||||
"already-current",
|
||||
]);
|
||||
assert.equal(manifest.entrypoints.guidedCommand, "client-config");
|
||||
assert.equal(manifest.entrypoints.mcpLoginTool, "nocobase_login");
|
||||
assert.equal(manifest.entrypoints.mcpLoginStatusTool, "nocobase_login_status");
|
||||
assert.equal(manifest.authentication.processRestartResumable, true);
|
||||
assert.equal(
|
||||
manifest.authentication.pendingAuthorizationStorage,
|
||||
"short-lived per-user private local file",
|
||||
);
|
||||
assert.equal(manifest.authentication.allowDeviceCodeExposure, false);
|
||||
assert.deepEqual(manifest.authentication.userVerificationDelivery, {
|
||||
fields: ["login_url", "verification_uri_complete", "user_code"],
|
||||
intendedRecipient: "requesting-user",
|
||||
channel: "current-private-conversation",
|
||||
shareableWithIntendedRecipient: true,
|
||||
publicSharingAllowed: false,
|
||||
containsBearerCredentials: false,
|
||||
containsDeviceCode: false,
|
||||
remoteDesktopRequired: false,
|
||||
});
|
||||
assert.deepEqual(packageJson.dependencies, undefined);
|
||||
assert.equal(manifest.runtime.runtimeNpmDependencies, 0);
|
||||
});
|
||||
|
||||
test("installation protocol resolves main once and never falls back to an old Release", () => {
|
||||
test("installation and update protocol resolves main once and never falls back to an old Release", () => {
|
||||
const guide = readFileSync(new URL("../INSTALL.md", import.meta.url), "utf8");
|
||||
const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8");
|
||||
const installUrl = "https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md";
|
||||
assert.match(guide, /branches\/main/);
|
||||
assert.match(guide, /40 位十六进制完整 SHA/);
|
||||
assert.match(guide, /resolve-latest-commit-then-pin/);
|
||||
assert.match(guide, /installation\.supportsInPlaceUpdate=true/);
|
||||
assert.match(guide, /installation\.sameCommitBuildSkip=true/);
|
||||
assert.match(guide, /archive\/<commit-sha>\.zip/);
|
||||
assert.match(guide, /archive\/<commit-sha>\.tar\.gz/);
|
||||
assert.match(guide, /Windows 使用 `.zip`/);
|
||||
assert.match(guide, /不得无条件调用.*TarFile\.extractall/);
|
||||
assert.match(guide, /inspect\.signature/);
|
||||
assert.match(guide, /extractfile\(\)/);
|
||||
assert.match(guide, /不得直接调用不带过滤器的 `extractall\(\)`/);
|
||||
assert.match(guide, /不需要重新解析 `main`、重新下载归档或中断用户/);
|
||||
assert.match(guide, /不得.*退回旧 Release/);
|
||||
assert.match(guide, /client-config --json/);
|
||||
assert.match(guide, /client-config \[--config <existing-config-path>\] \[--enable-writes\] --json/);
|
||||
assert.match(guide, /nocobase_login\(\{\}\)/);
|
||||
assert.match(guide, /stdio 进程.*退出或重启/);
|
||||
assert.match(guide, /下一次 `nocobase_login_status`.*恢复同一个链接/);
|
||||
assert.match(guide, /判断首次安装、更新或已有最新版/);
|
||||
assert.match(guide, /install-receipt\.json/);
|
||||
assert.match(guide, /保留.*CRM.*配置.*个人凭据.*只读\/写入模式/);
|
||||
assert.match(guide, /先调用 `crm_health`/);
|
||||
assert.match(guide, /不得仅因更新而要求用户重新登录/);
|
||||
assert.match(guide, /不得以“链接包含一次性凭据”为由拒绝发送/);
|
||||
assert.match(guide, /不得要求用户操作服务器远程桌面/);
|
||||
assert.ok(guide.includes(installUrl));
|
||||
assert.ok(readme.includes(installUrl));
|
||||
assert.match(readme, /帮我安装或更新 NocoBase CRM MCP/);
|
||||
assert.match(readme, /相同 Commit 不会重复构建/);
|
||||
assert.match(readme, /只有认证确实缺失或失效时才发起登录/);
|
||||
assert.match(readme, /无界面或远程环境也要发到当前私聊/);
|
||||
assert.match(readme, /不要要求远程桌面/);
|
||||
assert.match(readme, /nocobase_login/);
|
||||
const promptStart = readme.indexOf("请按以下 INSTALL.md");
|
||||
const promptEnd = readme.indexOf(installUrl, promptStart) + installUrl.length;
|
||||
assert.ok(promptStart >= 0 && promptEnd > promptStart);
|
||||
assert.ok([...readme.slice(promptStart, promptEnd)].length < 350);
|
||||
assert.doesNotMatch(guide, /下载本文件所在的同一固定 Release/);
|
||||
assert.doesNotMatch(readme, /<main 分支的 INSTALL\.md 链接>/);
|
||||
});
|
||||
|
||||
test("headless setup emits user-deliverable OAuth verification metadata", () => {
|
||||
const event = setupAuthorizationEvent({
|
||||
opened: false,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
userCode: "TEST-CODE",
|
||||
expiresIn: 600,
|
||||
});
|
||||
assert.equal(event.event, "authorization_required");
|
||||
assert.equal(event.authorization_type, "oauth_device_user_verification");
|
||||
assert.equal(event.login_url, "https://justconn.top/idpOAuth/device?user_code=TEST-CODE");
|
||||
assert.equal(event.verification_uri_complete, event.login_url);
|
||||
assert.equal(event.user_code, "TEST-CODE");
|
||||
assert.equal(event.intended_recipient, "requesting_user");
|
||||
assert.equal(event.delivery_channel, "current_private_conversation");
|
||||
assert.equal(event.shareable_with_requesting_user, true);
|
||||
assert.equal(event.private_conversation_required, true);
|
||||
assert.equal(event.public_sharing_allowed, false);
|
||||
assert.equal(event.contains_bearer_credentials, false);
|
||||
assert.equal(event.contains_device_code, false);
|
||||
assert.equal(event.remote_desktop_required, false);
|
||||
assert.match(String(event.next_action), /current private conversation/);
|
||||
assert.match(String(event.next_action), /remote desktop is not required/);
|
||||
assert.equal(Object.hasOwn(event, "access_token"), false);
|
||||
assert.equal(Object.hasOwn(event, "refresh_token"), false);
|
||||
assert.equal(Object.hasOwn(event, "device_code"), false);
|
||||
});
|
||||
|
||||
test("generated MCP config contains no Key, token, or environment secret", () => {
|
||||
const config = clientConfig("/private/config.json", false);
|
||||
const serialized = JSON.stringify(config);
|
||||
|
||||
+174
-1
@@ -1,6 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { type AuthorizationPrompt, loginWithBrowser, refreshOAuthCredentials } from "../src/oauth.js";
|
||||
import {
|
||||
type AuthorizationPrompt,
|
||||
beginBrowserLogin,
|
||||
loginWithBrowser,
|
||||
type PendingOAuthAuthorization,
|
||||
pollBrowserLogin,
|
||||
refreshOAuthCredentials,
|
||||
validatePendingOAuthAuthorization,
|
||||
} from "../src/oauth.js";
|
||||
|
||||
const discovery = {
|
||||
issuer: "https://justconn.top/api",
|
||||
@@ -77,6 +85,7 @@ test("uses NocoBase dynamic registration and browser device authorization withou
|
||||
assert.equal(credentials.accessToken, "oauth-access-token");
|
||||
assert.equal(credentials.refreshToken, "oauth-refresh-token");
|
||||
assert.equal(credentials.expiresAt, 901_000);
|
||||
assert.equal(calls.filter((call) => call.url.endsWith("/.well-known/openid-configuration")).length, 1);
|
||||
assert.doesNotMatch(JSON.stringify(credentials), /must-not-be-stored/);
|
||||
assert.deepEqual(prompt, {
|
||||
opened: true,
|
||||
@@ -98,6 +107,147 @@ test("uses NocoBase dynamic registration and browser device authorization withou
|
||||
assert.equal(deviceForm.get("resource"), "https://justconn.top/api/");
|
||||
});
|
||||
|
||||
test("resumes one persisted browser authorization without creating another link", async () => {
|
||||
let currentTime = 1_000;
|
||||
let deviceStarts = 0;
|
||||
let tokenPolls = 0;
|
||||
const fetchMock = (async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
|
||||
if (url.endsWith("/idpOAuth/device/auth")) {
|
||||
deviceStarts += 1;
|
||||
return jsonResponse({
|
||||
device_code: "private-resumable-device-code",
|
||||
user_code: "RESUME-CODE",
|
||||
verification_uri: "https://justconn.top/idpOAuth/device",
|
||||
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=RESUME-CODE",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/idpOAuth/token")) {
|
||||
tokenPolls += 1;
|
||||
return jsonResponse({
|
||||
access_token: "resumed-access-token",
|
||||
refresh_token: "resumed-refresh-token",
|
||||
token_type: "Bearer",
|
||||
scope: "api offline_access",
|
||||
expires_in: 900,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const authorization = await beginBrowserLogin({
|
||||
clientId: "desktop-client",
|
||||
fetch: fetchMock,
|
||||
now: () => currentTime,
|
||||
openBrowser: async () => false,
|
||||
});
|
||||
assert.equal(deviceStarts, 1);
|
||||
assert.equal(authorization.deviceCode, "private-resumable-device-code");
|
||||
assert.equal(authorization.nextPollAt, 6_000);
|
||||
|
||||
currentTime = 5_000;
|
||||
const tooEarly = await pollBrowserLogin(authorization, { fetch: fetchMock, now: () => currentTime });
|
||||
assert.equal(tooEarly.status, "authorization_pending");
|
||||
assert.equal(tokenPolls, 0);
|
||||
|
||||
currentTime = 6_000;
|
||||
const resumed = await pollBrowserLogin(authorization, { fetch: fetchMock, now: () => currentTime });
|
||||
assert.equal(resumed.status, "authenticated");
|
||||
if (resumed.status === "authenticated") {
|
||||
assert.equal(resumed.credentials.accessToken, "resumed-access-token");
|
||||
assert.equal(resumed.credentials.authorizationSessionId, authorization.sessionId);
|
||||
}
|
||||
assert.equal(tokenPolls, 1);
|
||||
assert.equal(deviceStarts, 1);
|
||||
});
|
||||
|
||||
test("normalizes pending and slow-down responses for resumable browser login", async () => {
|
||||
for (const scenario of [
|
||||
{ code: "authorization_pending", intervalSeconds: 5, nextPollAt: 11_000 },
|
||||
{ code: "slow_down", intervalSeconds: 10, nextPollAt: 16_000 },
|
||||
]) {
|
||||
let discoveries = 0;
|
||||
const fetchMock = (async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/openid-configuration")) {
|
||||
discoveries += 1;
|
||||
return jsonResponse(discovery);
|
||||
}
|
||||
if (url.endsWith("/idpOAuth/token")) return jsonResponse({ error: scenario.code }, 400);
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await pollBrowserLogin(persistedAuthorization(), {
|
||||
fetch: fetchMock,
|
||||
now: () => 6_000,
|
||||
});
|
||||
assert.equal(result.status, "authorization_pending");
|
||||
if (result.status === "authorization_pending") {
|
||||
assert.equal(result.authorization.intervalSeconds, scenario.intervalSeconds);
|
||||
assert.equal(result.authorization.nextPollAt, scenario.nextPollAt);
|
||||
}
|
||||
assert.equal(discoveries, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("maps cancelled and expired resumable browser login to stable errors", async () => {
|
||||
for (const scenario of [
|
||||
{ code: "access_denied", message: /cancelled/ },
|
||||
{ code: "expired_token", message: /expired/ },
|
||||
]) {
|
||||
const fetchMock = (async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
|
||||
if (url.endsWith("/idpOAuth/token")) return jsonResponse({ error: scenario.code }, 400);
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}) as typeof fetch;
|
||||
await assert.rejects(
|
||||
pollBrowserLogin(persistedAuthorization(), { fetch: fetchMock, now: () => 6_000 }),
|
||||
scenario.message,
|
||||
);
|
||||
}
|
||||
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
pollBrowserLogin(persistedAuthorization({ expiresAt: 6_000 }), {
|
||||
fetch: (async () => {
|
||||
calls += 1;
|
||||
throw new Error("must not fetch");
|
||||
}) as typeof fetch,
|
||||
now: () => 6_000,
|
||||
}),
|
||||
/expired/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("rejects a persisted browser authorization that was redirected off origin", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
validatePendingOAuthAuthorization({
|
||||
schemaVersion: 1,
|
||||
type: "oauth-device-authorization",
|
||||
sessionId: "session",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "client",
|
||||
deviceCode: "device-code",
|
||||
prompt: {
|
||||
opened: false,
|
||||
url: "https://evil.example/device",
|
||||
userCode: "CODE",
|
||||
expiresIn: 600,
|
||||
},
|
||||
expiresAt: 601_000,
|
||||
intervalSeconds: 5,
|
||||
nextPollAt: 6_000,
|
||||
}),
|
||||
/untrusted browser URL/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects OAuth discovery that points tokens at another origin", async () => {
|
||||
const fetchMock = (async () =>
|
||||
jsonResponse({ ...discovery, token_endpoint: "https://evil.example/token" })) as typeof fetch;
|
||||
@@ -148,3 +298,26 @@ function jsonResponse(value: unknown, status = 200): Response {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function persistedAuthorization(
|
||||
overrides: Partial<PendingOAuthAuthorization> = {},
|
||||
): PendingOAuthAuthorization {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: "oauth-device-authorization",
|
||||
sessionId: "persisted-session",
|
||||
issuer: "https://justconn.top/api",
|
||||
clientId: "desktop-client",
|
||||
deviceCode: "private-device-code",
|
||||
prompt: {
|
||||
opened: false,
|
||||
url: "https://justconn.top/idpOAuth/device?user_code=RESUME-CODE",
|
||||
userCode: "RESUME-CODE",
|
||||
expiresIn: 600,
|
||||
},
|
||||
expiresAt: 601_000,
|
||||
intervalSeconds: 5,
|
||||
nextPollAt: 6_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
+44
-1
@@ -23,8 +23,18 @@ function fakeAuthService(): AuthService {
|
||||
return {
|
||||
start: async () => ({
|
||||
status: "authorization_pending",
|
||||
authorization_type: "oauth_device_user_verification",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
user_code: "TEST-CODE",
|
||||
intended_recipient: "requesting_user",
|
||||
delivery_channel: "current_private_conversation",
|
||||
shareable_with_requesting_user: true,
|
||||
private_conversation_required: true,
|
||||
public_sharing_allowed: false,
|
||||
contains_bearer_credentials: false,
|
||||
contains_device_code: false,
|
||||
remote_desktop_required: false,
|
||||
}),
|
||||
status: async () => ({ status: "login_required" }),
|
||||
};
|
||||
@@ -85,6 +95,25 @@ test("read-only server exposes CRM tools plus browser login tools", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("login tools require a private user handoff instead of headless remote desktop", async () => {
|
||||
const connection = await connected(true);
|
||||
try {
|
||||
const tools = (await connection.client.listTools()).tools;
|
||||
const login = tools.find((tool) => tool.name === "nocobase_login");
|
||||
const status = tools.find((tool) => tool.name === "nocobase_login_status");
|
||||
assert.ok(login?.description);
|
||||
assert.ok(status?.description);
|
||||
assert.match(login.description, /MUST deliver login_url and user_code/);
|
||||
assert.match(login.description, /current private conversation/);
|
||||
assert.match(login.description, /headless server/);
|
||||
assert.match(login.description, /remote desktop is not required/);
|
||||
assert.match(login.description, /Never post them publicly/);
|
||||
assert.match(status.description, /private-delivery policy/);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("authentication failures tell the Agent to use the MCP browser login flow", async () => {
|
||||
const service = {
|
||||
crmHealth: async () => {
|
||||
@@ -114,10 +143,24 @@ test("browser login tool returns only the safe user-facing authorization fields"
|
||||
});
|
||||
assert.deepEqual(result.structuredContent, {
|
||||
status: "authorization_pending",
|
||||
authorization_type: "oauth_device_user_verification",
|
||||
login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE",
|
||||
user_code: "TEST-CODE",
|
||||
intended_recipient: "requesting_user",
|
||||
delivery_channel: "current_private_conversation",
|
||||
shareable_with_requesting_user: true,
|
||||
private_conversation_required: true,
|
||||
public_sharing_allowed: false,
|
||||
contains_bearer_credentials: false,
|
||||
contains_device_code: false,
|
||||
remote_desktop_required: false,
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(result), /access.token|refresh.token|device.code/i);
|
||||
const payload = result.structuredContent as Record<string, unknown>;
|
||||
assert.equal(Object.hasOwn(payload, "access_token"), false);
|
||||
assert.equal(Object.hasOwn(payload, "refresh_token"), false);
|
||||
assert.equal(Object.hasOwn(payload, "device_code"), false);
|
||||
assert.equal(Object.hasOwn(payload, "password"), false);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user