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 => { 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).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((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((resolve) => setImmediate(resolve)); const second = secondService.start(); await new Promise((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((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((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((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((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 { 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 { 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); }