465 lines
18 KiB
TypeScript
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);
|
|
}
|