mcporter/tests/oauth-persistence.test.ts
2026-05-14 16:32:37 +01:00

403 lines
16 KiB
TypeScript

import fs from 'node:fs/promises';
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 { readJsonFile } from '../src/fs-json.js';
import { buildOAuthPersistence, clearOAuthCaches, readCachedAccessToken } from '../src/oauth-persistence.js';
import { clearVaultEntry, loadVaultEntry, saveVaultEntry, vaultKeyForDefinition } from '../src/oauth-vault.js';
const authMocks = vi.hoisted(() => ({
discoverOAuthServerInfo: vi.fn(),
refreshAuthorization: vi.fn(),
}));
vi.mock('@modelcontextprotocol/sdk/client/auth.js', async (importOriginal) => ({
...(await importOriginal<typeof import('@modelcontextprotocol/sdk/client/auth.js')>()),
discoverOAuthServerInfo: authMocks.discoverOAuthServerInfo,
refreshAuthorization: authMocks.refreshAuthorization,
}));
const mkDef = (name: string, tokenCacheDir?: string): ServerDefinition => ({
name,
description: `${name} server`,
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
});
describe('oauth persistence', () => {
const originalEnv = { ...process.env };
const tempRoots: string[] = [];
let homedirSpy!: ReturnType<typeof vi.spyOn>;
let hasSpy = false;
afterEach(async () => {
vi.clearAllMocks();
process.env = { ...originalEnv };
if (hasSpy) {
homedirSpy.mockRestore();
hasSpy = false;
}
await Promise.all(tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it('prefers explicit tokenCacheDir before vault when reading tokens', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({ access_token: 'from-cache', token_type: 'Bearer' })
);
// Vault also contains a token, but cache dir should win.
const vaultPath = path.join(tmp, '.mcporter', 'credentials.json');
await fs.mkdir(path.dirname(vaultPath), { recursive: true });
const definition = mkDef('service', cacheDir);
const key = vaultKeyForDefinition(definition);
await fs.writeFile(
vaultPath,
JSON.stringify({
version: 1,
entries: {
[key]: {
updatedAt: new Date().toISOString(),
tokens: { access_token: 'from-vault', token_type: 'Bearer' },
serverName: 'service',
},
},
})
);
const persistence = await buildOAuthPersistence(definition);
expect(await persistence.readTokens()).toEqual({ access_token: 'from-cache', token_type: 'Bearer' });
// Saving should propagate to both stores.
await persistence.saveTokens({ access_token: 'new-token', token_type: 'Bearer' });
const cacheTokens = (await readJsonFile(path.join(cacheDir, 'tokens.json'))) as
| { access_token: string }
| undefined;
expect(cacheTokens?.access_token).toBe('new-token');
const entry = await loadVaultEntry(definition);
expect(entry?.tokens?.access_token).toBe('new-token');
});
it('migrates legacy per-server cache into the vault', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const legacyDir = path.join(tmp, '.mcporter', 'legacy-service');
await fs.mkdir(legacyDir, { recursive: true });
await fs.writeFile(
path.join(legacyDir, 'tokens.json'),
JSON.stringify({ access_token: 'legacy-token', token_type: 'Bearer' })
);
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as const;
const definition = mkDef('legacy-service');
const persistence = await buildOAuthPersistence(definition, logger);
expect(await persistence.readTokens()).toEqual({ access_token: 'legacy-token', token_type: 'Bearer' });
const entry = await loadVaultEntry(definition);
expect(entry?.tokens?.access_token).toBe('legacy-token');
expect(logger.info).toHaveBeenCalledWith("Migrated legacy OAuth cache for 'legacy-service' into vault.");
});
it('writes the shared vault under XDG_DATA_HOME when configured', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-xdg-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const definition = mkDef('xdg-service');
const persistence = await buildOAuthPersistence(definition);
await persistence.saveTokens({ access_token: 'xdg-token', token_type: 'Bearer' });
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
const key = vaultKeyForDefinition(definition);
const vault = (await readJsonFile(vaultPath)) as
| { entries: Record<string, { tokens?: { access_token?: string } }> }
| undefined;
expect(vault?.entries[key]?.tokens?.access_token).toBe('xdg-token');
});
it('serializes concurrent shared vault writes for different servers', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-race-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const definitions = Array.from({ length: 12 }, (_, index) => mkDef(`service-${index}`));
await Promise.all(
definitions.map((definition, index) =>
saveVaultEntry(definition, {
tokens: { access_token: `token-${index}`, token_type: 'Bearer' },
})
)
);
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
const vault = (await readJsonFile(vaultPath)) as
| { entries: Record<string, { tokens?: { access_token?: string } }> }
| undefined;
expect(Object.keys(vault?.entries ?? {})).toHaveLength(definitions.length);
for (const [index, definition] of definitions.entries()) {
expect(vault?.entries[vaultKeyForDefinition(definition)]?.tokens?.access_token).toBe(`token-${index}`);
}
});
it('does not create a vault file when clearing a missing vault entry', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-clear-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
await clearVaultEntry(mkDef('missing'), 'all');
await expect(fs.access(vaultPath)).rejects.toThrow();
await expect(fs.access(`${vaultPath}.lock`)).rejects.toThrow();
});
it('rewrites a corrupt vault file when clearing a missing vault entry', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-corrupt-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
await fs.mkdir(path.dirname(vaultPath), { recursive: true });
await fs.writeFile(vaultPath, '{"version":1,"entries": { bad', 'utf8');
await clearVaultEntry(mkDef('missing'), 'all');
expect(await readJsonFile(vaultPath)).toEqual({ version: 1, entries: {} });
await expect(fs.access(`${vaultPath}.lock`)).rejects.toThrow();
});
it.runIf(process.platform !== 'win32')('surfaces unreadable vault files', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-unreadable-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const definition = mkDef('unreadable');
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
await fs.mkdir(path.dirname(vaultPath), { recursive: true });
await fs.writeFile(vaultPath, JSON.stringify({ version: 1, entries: {} }), 'utf8');
try {
await fs.chmod(vaultPath, 0o000);
await expect(loadVaultEntry(definition)).rejects.toThrow();
} finally {
await fs.chmod(vaultPath, 0o600).catch(() => {});
}
});
it('clears vault, legacy, tokenCacheDir, and provider-specific caches', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({ access_token: 'cached', token_type: 'Bearer' })
);
const legacyDir = path.join(tmp, '.mcporter', 'gmail');
await fs.mkdir(legacyDir, { recursive: true });
await fs.writeFile(
path.join(legacyDir, 'tokens.json'),
JSON.stringify({ access_token: 'legacy', token_type: 'Bearer' })
);
const gmailLegacyFile = path.join(tmp, '.gmail-mcp', 'credentials.json');
await fs.mkdir(path.dirname(gmailLegacyFile), { recursive: true });
await fs.writeFile(gmailLegacyFile, '{}');
const vaultPath = path.join(tmp, '.mcporter', 'credentials.json');
await fs.mkdir(path.dirname(vaultPath), { recursive: true });
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as const;
const definition = mkDef('gmail', cacheDir);
const key = vaultKeyForDefinition(definition);
await fs.writeFile(
vaultPath,
JSON.stringify({
version: 1,
entries: {
[key]: { serverName: 'gmail', updatedAt: new Date().toISOString(), tokens: { access_token: 'vault' } },
},
})
);
await clearOAuthCaches(definition, logger, 'all');
await expect(fs.access(path.join(cacheDir, 'tokens.json'))).rejects.toThrow();
await expect(fs.access(path.join(legacyDir, 'tokens.json'))).rejects.toThrow();
await expect(fs.access(gmailLegacyFile)).rejects.toThrow();
const entry = await loadVaultEntry(definition);
expect(entry).toBeUndefined();
});
it('refreshes expired cached OAuth access tokens without starting a browser flow', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-refresh-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
await fs.writeFile(path.join(cacheDir, 'client.json'), JSON.stringify({ client_id: 'client-123' }));
authMocks.discoverOAuthServerInfo.mockResolvedValue({
authorizationServerUrl: 'https://auth.example.com',
authorizationServerMetadata: { token_endpoint: 'https://auth.example.com/token' },
resourceMetadata: { resource: 'https://example.com/mcp' },
});
authMocks.refreshAuthorization.mockResolvedValue({
access_token: 'fresh-token',
token_type: 'Bearer',
refresh_token: 'refresh-456',
expires_in: 3600,
});
const definition = mkDef('refresh-service', cacheDir);
await expect(readCachedAccessToken(definition)).resolves.toBe('fresh-token');
expect(authMocks.refreshAuthorization).toHaveBeenCalledWith(
'https://auth.example.com',
expect.objectContaining({
clientInformation: { client_id: 'client-123' },
refreshToken: 'refresh-123',
resource: new URL('https://example.com/mcp'),
})
);
const persisted = (await readJsonFile(path.join(cacheDir, 'tokens.json'))) as
| { access_token?: string; refresh_token?: string; expires_at?: number }
| undefined;
expect(persisted?.access_token).toBe('fresh-token');
expect(persisted?.refresh_token).toBe('refresh-456');
expect(persisted?.expires_at).toBeGreaterThan(Math.floor(Date.now() / 1000));
});
it('omits OAuth resource during silent refresh when protected-resource metadata is absent', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-refresh-no-resource-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
await fs.writeFile(path.join(cacheDir, 'client.json'), JSON.stringify({ client_id: 'client-123' }));
authMocks.discoverOAuthServerInfo.mockResolvedValue({
authorizationServerUrl: 'https://auth.example.com',
authorizationServerMetadata: { token_endpoint: 'https://auth.example.com/token' },
});
authMocks.refreshAuthorization.mockResolvedValue({
access_token: 'fresh-token',
token_type: 'Bearer',
refresh_token: 'refresh-456',
expires_in: 3600,
});
const definition = mkDef('refresh-without-resource-service', cacheDir);
await expect(readCachedAccessToken(definition)).resolves.toBe('fresh-token');
const [, options] = authMocks.refreshAuthorization.mock.calls[0] ?? [];
expect(options).not.toHaveProperty('resource');
});
it('keeps the original cached OAuth token when silent refresh fails', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-refresh-fail-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
await fs.writeFile(path.join(cacheDir, 'client.json'), JSON.stringify({ client_id: 'client-123' }));
authMocks.discoverOAuthServerInfo.mockResolvedValue({ authorizationServerUrl: 'https://auth.example.com' });
authMocks.refreshAuthorization.mockRejectedValue(new Error('invalid_grant'));
const definition = mkDef('refresh-fail-service', cacheDir);
await expect(readCachedAccessToken(definition)).resolves.toBe('expired-token');
const persisted = (await readJsonFile(path.join(cacheDir, 'tokens.json'))) as
| { access_token?: string; refresh_token?: string }
| undefined;
expect(persisted).toEqual(
expect.objectContaining({
access_token: 'expired-token',
refresh_token: 'refresh-123',
})
);
});
it('uses unexpired cached OAuth tokens without refresh', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-current-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'current-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) + 3600,
})
);
const definition = mkDef('current-service', cacheDir);
await expect(readCachedAccessToken(definition)).resolves.toBe('current-token');
expect(authMocks.refreshAuthorization).not.toHaveBeenCalled();
});
});