🔌 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:
Federico Ruggi 2025-09-11 00:53:34 +02:00 committed by GitHub
parent 5667cc9702
commit 04c3a5a861
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 725 additions and 6 deletions

View file

@ -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 /),
}),
}),
);
});
});
});

View 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);
});
});
});