mcporter/tests/oauth-session.test.ts
Krasimir Kralev 53747cac63
fix(oauth): degrade to re-auth on corrupt credential cache instead of crashing (#208)
* fix(oauth): degrade to re-auth on corrupt credential cache instead of crashing

DirectoryPersistence.readTokens/readClientInfo/readState routed a corrupt
(truncated or malformed) cache file's JSON.parse SyntaxError straight up
through CompositePersistence, crashing the MCP connection instead of degrading
to re-auth. Every sibling reader already tolerates this: VaultPersistence
(oauth-vault.ts) catches SyntaxError to rebuild, and the daemon/server-proxy
readers wrap in catch-all. These three were the lone outliers.

Wrap the three JSON readers in a narrow helper that maps only SyntaxError to
undefined; genuine I/O faults still propagate. readJsonFile is left untouched so
the vault keeps distinguishing corrupt-from-missing for its repair path.

Fixes #207.

* fix(oauth): keep corrupt OAuth state failing closed (addresses Codex P2)

Codex review on #208 noted that making readState() corrupt-tolerant skips the
CSRF state check on the authorization callback: oauth.ts only rejects when the
stored expectedState is truthy, so a corrupt state.txt degrading to undefined
would let a mismatched/absent callback state through.

Narrow the tolerance to the credential caches only (readTokens/readClientInfo).
readState() keeps throwing on corrupt input so the OAuth flow fails closed.
Test now asserts state reads reject with SyntaxError while tokens/client degrade.

* test(oauth): preserve callback cleanup for read errors

---------

Co-authored-by: KrasimirKralev <krasi@idrobots.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 17:32:24 +08:00

323 lines
12 KiB
TypeScript

import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { __oauthInternals, createOAuthSession } from '../src/oauth.js';
type StatefulProvider = {
redirectUrl: string | URL;
state: () => Promise<string>;
redirectToAuthorization: (authorizationUrl: URL) => Promise<void>;
hasAuthorizationRedirectStarted: () => boolean;
};
const requestStatus = (target: URL): Promise<number> =>
new Promise((resolve, reject) => {
const req = http.request(
{
hostname: target.hostname,
port: target.port,
path: `${target.pathname}${target.search}`,
family: 4,
method: 'GET',
},
(res) => {
const status = res.statusCode ?? 0;
res.resume();
resolve(status);
}
);
req.on('error', reject);
req.end();
});
describe('FileOAuthClientProvider session lifecycle', () => {
const tempDirs: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
delete process.env.MCPORTER_TEST_OAUTH_SECRET;
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it('rejects pending authorization waits when the session closes early', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
const definition: ServerDefinition = {
name: 'test-oauth',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = await createOAuthSession(definition, logger);
const waitPromise = session.waitForAuthorizationCode();
await session.close();
await expect(waitPromise).rejects.toThrow(/closed before receiving authorization code/i);
});
it('uses oauthScope when explicitly configured', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
const definition: ServerDefinition = {
name: 'test-oauth-scope',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
oauthScope: 'openid email profile',
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = await createOAuthSession(definition, logger);
expect((session.provider as { clientMetadata: { scope?: string } }).clientMetadata.scope).toBe(
'openid email profile'
);
await session.close();
});
it('returns configured static OAuth client information without dynamic registration', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
process.env.MCPORTER_TEST_OAUTH_SECRET = 'client-secret-value';
const definition: ServerDefinition = {
name: 'test-oauth-static-client',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
oauthClientId: 'client-123',
oauthClientSecretEnv: 'MCPORTER_TEST_OAUTH_SECRET',
oauthTokenEndpointAuthMethod: 'client_secret_post',
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = await createOAuthSession(definition, logger);
const clientInfo = await session.provider.clientInformation();
expect(clientInfo).toMatchObject({
client_id: 'client-123',
client_secret: 'client-secret-value',
token_endpoint_auth_method: 'client_secret_post',
});
await session.close();
});
it('clears stale client registrations when redirect URI changes with dynamic ports', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
await fs.writeFile(
path.join(tokenCacheDir, 'client.json'),
JSON.stringify({ redirect_uris: ['http://127.0.0.1:9999/callback'] }, null, 2),
'utf8'
);
const definition: ServerDefinition = {
name: 'test-oauth-stale-client',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = await createOAuthSession(definition, logger);
await session.close();
await expect(fs.readFile(path.join(tokenCacheDir, 'client.json'), 'utf8')).rejects.toMatchObject({
code: 'ENOENT',
});
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('clearing stale client registration'));
});
it('closes the callback server when stale-client reads have I/O errors', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
const definition: ServerDefinition = {
name: 'test-oauth-read-failure',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const readError = Object.assign(new Error('permission denied'), { code: 'EACCES' });
const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError);
const originalCreateServer = http.createServer.bind(http);
const createdServers: http.Server[] = [];
const createServerSpy = vi.spyOn(http, 'createServer').mockImplementation((...args) => {
const server = originalCreateServer(...args);
createdServers.push(server);
return server;
});
try {
await expect(createOAuthSession(definition, logger)).rejects.toMatchObject({ code: 'EACCES' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(createdServers).toHaveLength(1);
expect(createdServers[0]?.listening).toBe(false);
} finally {
readFileSpy.mockRestore();
createServerSpy.mockRestore();
}
});
it('resolves waiters created before redirectToAuthorization', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
const definition: ServerDefinition = {
name: 'test-oauth-wait-before-redirect',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = await createOAuthSession(definition, logger);
const provider = session.provider as StatefulProvider;
vi.spyOn(__oauthInternals, 'openExternal').mockImplementation(() => {});
const waitPromise = session.waitForAuthorizationCode();
await provider.redirectToAuthorization(new URL('https://example.com/auth'));
const callback = new URL(String(provider.redirectUrl));
callback.hostname = '127.0.0.1';
callback.searchParams.set('code', 'prewait-code');
const status = await requestStatus(callback);
expect(status).toBe(200);
await expect(waitPromise).resolves.toBe('prewait-code');
await session.close();
});
it('does not replace the pending authorization deferred on repeated redirect calls', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
const definition: ServerDefinition = {
name: 'test-oauth-repeat-redirect',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = await createOAuthSession(definition, logger);
const provider = session.provider as StatefulProvider;
vi.spyOn(__oauthInternals, 'openExternal').mockImplementation(() => {});
const waitPromise = session.waitForAuthorizationCode();
await provider.redirectToAuthorization(new URL('https://example.com/auth-one'));
await provider.redirectToAuthorization(new URL('https://example.com/auth-two'));
const callback = new URL(String(provider.redirectUrl));
callback.hostname = '127.0.0.1';
callback.searchParams.set('code', 'stable-deferred-code');
const status = await requestStatus(callback);
expect(status).toBe(200);
await expect(waitPromise).resolves.toBe('stable-deferred-code');
await session.close();
});
it('suppresses browser launch and reports authorization URL when configured', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
const definition: ServerDefinition = {
name: 'test-oauth-no-browser-url',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const onAuthorizationUrl = vi.fn();
const session = await createOAuthSession(definition, logger, {
suppressBrowserLaunch: true,
onAuthorizationUrl,
});
const provider = session.provider as StatefulProvider;
const openSpy = vi.spyOn(__oauthInternals, 'openExternal').mockImplementation(() => {});
const authorizationUrl = new URL('https://example.com/auth?code=xyz');
const waitPromise = session.waitForAuthorizationCode().catch(() => undefined);
await provider.redirectToAuthorization(authorizationUrl);
expect(openSpy).not.toHaveBeenCalled();
expect(onAuthorizationUrl).toHaveBeenCalledWith({
authorizationUrl: authorizationUrl.toString(),
redirectUrl: String(provider.redirectUrl),
});
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining(`visit ${authorizationUrl.toString()} manually`)
);
expect(provider.hasAuthorizationRedirectStarted()).toBe(true);
await session.close();
await waitPromise;
});
it('logs the manual OAuth URL at warn level for headless terminals (#139)', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
const definition: ServerDefinition = {
name: 'test-oauth-headless-url',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = await createOAuthSession(definition, logger);
const provider = session.provider as StatefulProvider;
vi.spyOn(__oauthInternals, 'openExternal').mockImplementation(() => {});
const authorizationUrl = new URL('https://example.com/auth?code=xyz');
const waitPromise = session.waitForAuthorizationCode().catch(() => undefined);
await provider.redirectToAuthorization(authorizationUrl);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(`visit ${authorizationUrl.toString()} manually`));
await session.close();
await waitPromise;
});
});