Files
nocobase-mcp/test/auth-service.test.ts
nac 9f3d3ad5fc
CI / test (macos-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
feat: harden install and resumable OAuth login
2026-08-28 07:01:20 +00:00

465 lines
18 KiB
TypeScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
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 { BrowserLoginPollResult, OAuthCredentials, PendingOAuthAuthorization } from "../src/oauth.js";
const oauthCredentials: OAuthCredentials = {
type: "oauth",
issuer: "https://justconn.top/api",
clientId: "desktop-client",
accessToken: "private-access-token",
refreshToken: "private-refresh-token",
tokenType: "Bearer",
scope: "api offline_access",
expiresAt: 901_000,
};
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");
const pendingPath = pendingAuthorizationPath(path);
let currentTime = 1_000;
let polls = 0;
let verified = 0;
let reloaded = 0;
const service = new NocoBaseAuthService({
credentialsFile: path,
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;
},
onCredentialsSaved: () => {
reloaded += 1;
},
});
const started = await service.start();
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);
currentTime = 6_000;
assert.equal((await service.status()).status, "authenticated");
assert.equal(polls, 1);
assert.equal(verified, 1);
assert.equal(reloaded, 1);
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("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,
now: () => 1_000,
beginLogin: async () => {
starts += 1;
return pendingAuthorization();
},
});
assert.equal((await service.start()).status, "authenticated");
assert.equal(starts, 0);
assert.equal((await service.start({ force: true })).status, "authorization_pending");
assert.equal(starts, 1);
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);
}