mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-18 00:18:09 +01:00
🔌 feat: Revoke MCP OAuth Credentials (#9464)
* revocation metadata fields * store metadata * get client info and meta * revoke oauth tokens * delete flow * uninstall oauth mcp * revoke button * revoke oauth refactor, add comments, test * adjust for clarity * test deleteFlow * handle metadata type * no mutation * adjust for clarity * styling * restructure for clarity * move token-specific stuff * use mcpmanager's oauth servers * fix typo * fix addressing of oauth prop * log prefix * remove debug log
This commit is contained in:
parent
5667cc9702
commit
04c3a5a861
12 changed files with 725 additions and 6 deletions
|
|
@ -187,4 +187,195 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeOAuthToken', () => {
|
||||
const mockServerName = 'test-server';
|
||||
const mockToken = 'test-token-12345';
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const mockFetch = jest.fn() as unknown as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should successfully revoke an access token with client_secret_basic auth', async () => {
|
||||
const metadata = {
|
||||
serverUrl: 'https://auth.example.com',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
revocationEndpoint: 'https://auth.example.com/oauth/revoke',
|
||||
revocationEndpointAuthMethodsSupported: ['client_secret_basic'],
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'access', metadata);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(new URL('https://auth.example.com/oauth/revoke'), {
|
||||
method: 'POST',
|
||||
body: 'token=test-token-12345&token_type_hint=access_token',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully revoke a refresh token with client_secret_basic auth', async () => {
|
||||
const metadata = {
|
||||
serverUrl: 'https://auth.example.com',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
revocationEndpoint: 'https://auth.example.com/oauth/revoke',
|
||||
revocationEndpointAuthMethodsSupported: ['client_secret_basic'],
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(new URL('https://auth.example.com/oauth/revoke'), {
|
||||
method: 'POST',
|
||||
body: 'token=test-token-12345&token_type_hint=refresh_token',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully revoke an access token with client_secret_post auth', async () => {
|
||||
const metadata = {
|
||||
serverUrl: 'https://auth.example.com',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
revocationEndpoint: 'https://auth.example.com/oauth/revoke',
|
||||
revocationEndpointAuthMethodsSupported: ['client_secret_post'],
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'access', metadata);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(new URL('https://auth.example.com/oauth/revoke'), {
|
||||
method: 'POST',
|
||||
body: 'token=test-token-12345&token_type_hint=access_token&client_secret=test-client-secret&client_id=test-client-id',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fallback to /revoke endpoint when revocationEndpoint is not provided', async () => {
|
||||
const metadata = {
|
||||
serverUrl: 'https://auth.example.com',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
new URL('https://auth.example.com/revoke'),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to client_secret_basic auth when revocationEndpointAuthMethodsSupported is not provided', async () => {
|
||||
const metadata = {
|
||||
serverUrl: 'https://auth.example.com',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
revocationEndpoint: 'https://auth.example.com/oauth/revoke',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(URL),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.stringMatching(/^Basic /),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when the revocation request fails', async () => {
|
||||
const metadata = {
|
||||
serverUrl: 'https://auth.example.com',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
revocationEndpoint: 'https://auth.example.com/oauth/revoke',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 418,
|
||||
} as Response);
|
||||
|
||||
await expect(
|
||||
MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata),
|
||||
).rejects.toThrow('Token revocation failed: HTTP 418');
|
||||
});
|
||||
|
||||
it('should prioritize client_secret_basic over other auth methods', async () => {
|
||||
const metadata = {
|
||||
serverUrl: 'https://auth.example.com',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
revocationEndpoint: 'https://auth.example.com/oauth/revoke',
|
||||
revocationEndpointAuthMethodsSupported: [
|
||||
'client_secret_post',
|
||||
'client_secret_basic',
|
||||
'some_other_method',
|
||||
],
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(URL),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.stringMatching(/^Basic /),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
193
packages/api/src/mcp/__tests__/tokens.test.ts
Normal file
193
packages/api/src/mcp/__tests__/tokens.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { MCPTokenStorage } from '~/mcp/oauth/tokens';
|
||||
import { decryptV2 } from '~/crypto';
|
||||
import type { TokenMethods, IToken } from '@librechat/data-schemas';
|
||||
import { Types } from 'mongoose';
|
||||
|
||||
jest.mock('~/crypto', () => ({
|
||||
decryptV2: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDecryptV2 = decryptV2 as jest.MockedFunction<typeof decryptV2>;
|
||||
|
||||
describe('MCPTokenStorage', () => {
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('deleteUserTokens', () => {
|
||||
const userId = '000000001111111122222222';
|
||||
const serverName = 'test-server';
|
||||
let mockDeleteToken: jest.MockedFunction<
|
||||
(filter: { userId: string; type: string; identifier: string }) => Promise<void>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDeleteToken = jest.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should delete all OAuth-related tokens for a user and server', async () => {
|
||||
await MCPTokenStorage.deleteUserTokens({
|
||||
userId,
|
||||
serverName,
|
||||
deleteToken: mockDeleteToken,
|
||||
});
|
||||
|
||||
// Verify all three token types were deleted with correct identifiers
|
||||
expect(mockDeleteToken).toHaveBeenCalledTimes(3);
|
||||
expect(mockDeleteToken).toHaveBeenCalledWith({
|
||||
userId,
|
||||
type: 'mcp_oauth_client',
|
||||
identifier: `mcp:${serverName}:client`,
|
||||
});
|
||||
expect(mockDeleteToken).toHaveBeenCalledWith({
|
||||
userId,
|
||||
type: 'mcp_oauth',
|
||||
identifier: `mcp:${serverName}`,
|
||||
});
|
||||
expect(mockDeleteToken).toHaveBeenCalledWith({
|
||||
userId,
|
||||
type: 'mcp_oauth_refresh',
|
||||
identifier: `mcp:${serverName}:refresh`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deletion errors gracefully', async () => {
|
||||
mockDeleteToken.mockRejectedValueOnce(new Error('Deletion failed'));
|
||||
|
||||
await expect(
|
||||
MCPTokenStorage.deleteUserTokens({
|
||||
userId,
|
||||
serverName,
|
||||
deleteToken: mockDeleteToken,
|
||||
}),
|
||||
).rejects.toThrow('Deletion failed');
|
||||
|
||||
expect(mockDeleteToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClientInfoAndMetadata', () => {
|
||||
const userId = '000000001111111122222222';
|
||||
const serverName = 'test-server';
|
||||
const identifier = `mcp:${serverName}`;
|
||||
let mockFindToken: jest.MockedFunction<TokenMethods['findToken']>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFindToken = jest.fn();
|
||||
});
|
||||
|
||||
it('should return null when no client info token exists', async () => {
|
||||
mockFindToken.mockResolvedValue(null);
|
||||
|
||||
const result = await MCPTokenStorage.getClientInfoAndMetadata({
|
||||
userId,
|
||||
serverName,
|
||||
findToken: mockFindToken,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockFindToken).toHaveBeenCalledWith({
|
||||
userId,
|
||||
type: 'mcp_oauth_client',
|
||||
identifier: `${identifier}:client`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return client info and metadata when token exists', async () => {
|
||||
const clientInfo = {
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-secret',
|
||||
};
|
||||
|
||||
const metadata = new Map([
|
||||
['serverUrl', 'https://test.example.com'],
|
||||
['state', 'test-state'],
|
||||
]);
|
||||
|
||||
const mockToken: IToken = {
|
||||
userId: new Types.ObjectId(userId),
|
||||
type: 'mcp_oauth_client',
|
||||
identifier: `${identifier}:client`,
|
||||
token: 'encrypted-token',
|
||||
metadata,
|
||||
} as IToken;
|
||||
|
||||
mockFindToken.mockResolvedValue(mockToken);
|
||||
mockDecryptV2.mockResolvedValue(JSON.stringify(clientInfo));
|
||||
|
||||
const result = await MCPTokenStorage.getClientInfoAndMetadata({
|
||||
userId,
|
||||
serverName,
|
||||
findToken: mockFindToken,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.clientInfo).toEqual(clientInfo);
|
||||
expect(result?.clientMetadata).toEqual({
|
||||
serverUrl: 'https://test.example.com',
|
||||
state: 'test-state',
|
||||
});
|
||||
expect(mockDecryptV2).toHaveBeenCalledWith('encrypted-token');
|
||||
});
|
||||
|
||||
it('should handle empty metadata', async () => {
|
||||
const clientInfo = {
|
||||
client_id: 'test-client-id',
|
||||
};
|
||||
|
||||
const mockToken: IToken = {
|
||||
userId: new Types.ObjectId(userId),
|
||||
type: 'mcp_oauth_client',
|
||||
identifier: `${identifier}:client`,
|
||||
token: 'encrypted-token',
|
||||
} as IToken;
|
||||
|
||||
mockFindToken.mockResolvedValue(mockToken);
|
||||
mockDecryptV2.mockResolvedValue(JSON.stringify(clientInfo));
|
||||
|
||||
const result = await MCPTokenStorage.getClientInfoAndMetadata({
|
||||
userId,
|
||||
serverName,
|
||||
findToken: mockFindToken,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.clientInfo).toEqual(clientInfo);
|
||||
expect(result?.clientMetadata).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle metadata as plain object', async () => {
|
||||
const clientInfo = {
|
||||
client_id: 'test-client-id',
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
serverUrl: 'https://test.example.com',
|
||||
state: 'test-state',
|
||||
};
|
||||
|
||||
const mockToken: IToken = {
|
||||
userId: new Types.ObjectId(userId),
|
||||
type: 'mcp_oauth_client',
|
||||
identifier: `${identifier}:client`,
|
||||
token: 'encrypted-token',
|
||||
metadata: metadata as unknown, // runtime check
|
||||
} as IToken;
|
||||
|
||||
mockFindToken.mockResolvedValue(mockToken);
|
||||
mockDecryptV2.mockResolvedValue(JSON.stringify(clientInfo));
|
||||
|
||||
const result = await MCPTokenStorage.getClientInfoAndMetadata({
|
||||
userId,
|
||||
serverName,
|
||||
findToken: mockFindToken,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.clientInfo).toEqual(clientInfo);
|
||||
expect(result?.clientMetadata).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue