mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-17 16:08:10 +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
|
|
@ -643,4 +643,68 @@ export class MCPOAuthHandler {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes OAuth tokens at the authorization server (RFC 7009)
|
||||
*/
|
||||
public static async revokeOAuthToken(
|
||||
serverName: string,
|
||||
token: string,
|
||||
tokenType: 'refresh' | 'access',
|
||||
metadata: {
|
||||
serverUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
revocationEndpoint?: string;
|
||||
revocationEndpointAuthMethodsSupported?: string[];
|
||||
},
|
||||
): Promise<void> {
|
||||
// build the revoke URL, falling back to the server URL + /revoke if no revocation endpoint is provided
|
||||
const revokeUrl: URL =
|
||||
metadata.revocationEndpoint != null
|
||||
? new URL(metadata.revocationEndpoint)
|
||||
: new URL('/revoke', metadata.serverUrl);
|
||||
|
||||
// detect auth method to use
|
||||
const authMethods = metadata.revocationEndpointAuthMethodsSupported ?? [
|
||||
'client_secret_basic', // RFC 8414 (https://datatracker.ietf.org/doc/html/rfc8414)
|
||||
];
|
||||
const usesBasicAuth = authMethods.includes('client_secret_basic');
|
||||
const usesClientSecretPost = authMethods.includes('client_secret_post');
|
||||
|
||||
// init the request headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
|
||||
// init the request body
|
||||
const body = new URLSearchParams({ token });
|
||||
body.set('token_type_hint', tokenType === 'refresh' ? 'refresh_token' : 'access_token');
|
||||
|
||||
// process auth method
|
||||
if (usesBasicAuth) {
|
||||
// encode the client id and secret and add to the headers
|
||||
const credentials = Buffer.from(`${metadata.clientId}:${metadata.clientSecret}`).toString(
|
||||
'base64',
|
||||
);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
} else if (usesClientSecretPost) {
|
||||
// add the client id and secret to the body
|
||||
body.set('client_secret', metadata.clientSecret);
|
||||
body.set('client_id', metadata.clientId);
|
||||
}
|
||||
|
||||
// perform the revoke request
|
||||
logger.info(`[MCPOAuth] Revoking tokens for ${serverName} via ${revokeUrl.toString()}`);
|
||||
const response = await fetch(revokeUrl, {
|
||||
method: 'POST',
|
||||
body: body.toString(),
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`[MCPOAuth] Token revocation failed for ${serverName}: HTTP ${response.status}`);
|
||||
throw new Error(`Token revocation failed: HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import type { OAuthTokens, OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { TokenMethods, IToken } from '@librechat/data-schemas';
|
||||
import type { MCPOAuthTokens, ExtendedOAuthTokens } from './types';
|
||||
import type { MCPOAuthTokens, ExtendedOAuthTokens, OAuthMetadata } from './types';
|
||||
import { encryptV2, decryptV2 } from '~/crypto';
|
||||
import { isSystemUserId } from '~/mcp/enum';
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ interface StoreTokensParams {
|
|||
updateToken?: TokenMethods['updateToken'];
|
||||
findToken?: TokenMethods['findToken'];
|
||||
clientInfo?: OAuthClientInformation;
|
||||
metadata?: OAuthMetadata;
|
||||
/** Optional: Pass existing token state to avoid duplicate DB calls */
|
||||
existingTokens?: {
|
||||
accessToken?: IToken | null;
|
||||
|
|
@ -55,6 +56,7 @@ export class MCPTokenStorage {
|
|||
findToken,
|
||||
clientInfo,
|
||||
existingTokens,
|
||||
metadata,
|
||||
}: StoreTokensParams): Promise<void> {
|
||||
const logPrefix = this.getLogPrefix(userId, serverName);
|
||||
|
||||
|
|
@ -188,6 +190,7 @@ export class MCPTokenStorage {
|
|||
identifier: `${identifier}:client`,
|
||||
token: encryptedClientInfo,
|
||||
expiresIn: 365 * 24 * 60 * 60,
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Check if client info already exists and update if it does
|
||||
|
|
@ -379,4 +382,86 @@ export class MCPTokenStorage {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async getClientInfoAndMetadata({
|
||||
userId,
|
||||
serverName,
|
||||
findToken,
|
||||
}: {
|
||||
userId: string;
|
||||
serverName: string;
|
||||
findToken: TokenMethods['findToken'];
|
||||
}): Promise<{
|
||||
clientInfo: OAuthClientInformation;
|
||||
clientMetadata: Record<string, unknown>;
|
||||
} | null> {
|
||||
const identifier = `mcp:${serverName}`;
|
||||
|
||||
const clientInfoData: IToken | null = await findToken({
|
||||
userId,
|
||||
type: 'mcp_oauth_client',
|
||||
identifier: `${identifier}:client`,
|
||||
});
|
||||
if (clientInfoData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = await decryptV2(clientInfoData.token);
|
||||
const clientInfo = JSON.parse(tokenData);
|
||||
|
||||
// get metadata from the token as a plain object. While it's defined as a Map in the database type, it's a plain object at runtime.
|
||||
function getMetadata(
|
||||
metadata: Map<string, unknown> | Record<string, unknown> | null,
|
||||
): Record<string, unknown> {
|
||||
if (metadata == null) {
|
||||
return {};
|
||||
}
|
||||
if (metadata instanceof Map) {
|
||||
return Object.fromEntries(metadata);
|
||||
}
|
||||
return { ...(metadata as Record<string, unknown>) };
|
||||
}
|
||||
const clientMetadata = getMetadata(clientInfoData.metadata ?? null);
|
||||
|
||||
return {
|
||||
clientInfo,
|
||||
clientMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all OAuth-related tokens for a specific user and server
|
||||
*/
|
||||
static async deleteUserTokens({
|
||||
userId,
|
||||
serverName,
|
||||
deleteToken,
|
||||
}: {
|
||||
userId: string;
|
||||
serverName: string;
|
||||
deleteToken: (filter: { userId: string; type: string; identifier: string }) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const identifier = `mcp:${serverName}`;
|
||||
|
||||
// delete client info token
|
||||
await deleteToken({
|
||||
userId,
|
||||
type: 'mcp_oauth_client',
|
||||
identifier: `${identifier}:client`,
|
||||
});
|
||||
|
||||
// delete access token
|
||||
await deleteToken({
|
||||
userId,
|
||||
type: 'mcp_oauth',
|
||||
identifier,
|
||||
});
|
||||
|
||||
// delete refresh token
|
||||
await deleteToken({
|
||||
userId,
|
||||
type: 'mcp_oauth_refresh',
|
||||
identifier: `${identifier}:refresh`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export interface OAuthMetadata {
|
|||
token_endpoint_auth_methods_supported?: string[];
|
||||
/** Code challenge methods supported */
|
||||
code_challenge_methods_supported?: string[];
|
||||
/** Revocation endpoint */
|
||||
revocation_endpoint?: string;
|
||||
/** Revocation endpoint auth methods supported */
|
||||
revocation_endpoint_auth_methods_supported?: string[];
|
||||
}
|
||||
|
||||
export interface OAuthProtectedResourceMetadata {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue