feat: harden install and resumable OAuth login
CI / test (macos-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s

This commit is contained in:
nac
2026-08-28 07:01:20 +00:00
parent 6b5b3a413a
commit 9f3d3ad5fc
19 changed files with 1721 additions and 472 deletions
+404 -45
View File
@@ -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);
}
+85 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}