diff --git a/docs/mcp_oauth_servers.md b/docs/mcp_oauth_servers.md index 8c75d7a4..b6ef9703 100644 --- a/docs/mcp_oauth_servers.md +++ b/docs/mcp_oauth_servers.md @@ -9,26 +9,31 @@ This feature adds automatic OAuth 2.0 authentication support for remote MCP serv - **🛡️ PKCE Security**: Implements RFC 7636 (Proof Key for Code Exchange) for secure public client authentication - **🌐 Public Client Support**: Works with servers that don't require client_id (like Notion MCP) - **🔄 Token Management**: Automatic token refresh and expiry handling -- **🚫 Extension Filtering**: Filters out browser extension interference during OAuth callbacks +- **🚫 Extension Filtering**: Filters out browser extension interference during OAuth callbacks (web) +- **🖥️ Desktop Support**: Full OAuth support for Windows, macOS, and Linux desktop applications ## Platform Support **✅ Web Platform**: Full OAuth support with popup-based authentication flow -**❌ Mobile/Desktop**: OAuth authentication is **web-only** due to browser security requirements +**✅ Desktop Platform** (Windows, macOS, Linux): Full OAuth support with external browser and local callback server +**❌ Mobile**: OAuth authentication is not yet supported on mobile platforms -On non-web platforms: -- OAuth discovery still works (detects requirements) -- OAuth authentication throws `UnsupportedError` -- Fallback to manual configuration or other auth methods +On desktop platforms: +- OAuth discovery works automatically +- OAuth authentication opens external browser for user authorization +- Local HTTP server handles OAuth callback securely +- Same PKCE security as web implementation ## Tested OAuth Providers - ✅ **Notion MCP** (`https://mcp.notion.com/mcp`) -- ✅ **Atlassian MCP** (specific URL varies) +- ✅ **Atlassian MCP** (`https://mcp.atlassian.com/v1/mcp`) - 🔄 **Other RFC 8414 compliant servers** (should work automatically) ## How It Works +### Web Platform + 1. **Discovery Phase**: When you enter an MCP server URL, the system: - Checks `/.well-known/oauth-authorization-server` for OAuth metadata - Attempts dynamic client registration if available @@ -45,8 +50,23 @@ On non-web platforms: - Handles token refresh when needed - Validates token expiry +### Desktop Platform (Windows, macOS, Linux) + +1. **Discovery Phase**: Same as web platform + +2. **Authentication Phase**: + - Starts local HTTP callback server on available port + - Opens system default browser for OAuth authorization + - Handles PKCE code challenge/verifier generation + - Receives OAuth callback via local server with state validation + - Exchanges authorization code for access token + - Automatically closes local server after completion + +3. **Usage Phase**: Same as web platform + ## Architecture +### Web Platform ``` ┌─────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │ MCP Server │ │ OAuth Discovery │ │ OAuth Handler │ @@ -65,6 +85,25 @@ On non-web platforms: └────────────────────┘ ``` +### Desktop Platform +``` +┌─────────────────┐ ┌────────────────────┐ ┌─────────────────┐ +│ MCP Server │ │ OAuth Discovery │ │ OAuth Handler │ +│ │◄──►│ │◄──►│ │ +│ /.well-known/ │ │ RFC 8414 Compliant │ │ PKCE + Browser │ +│ oauth-auth... │ │ │ │ Local Server │ +└─────────────────┘ └────────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌────────────────────┐ ┌─────────────────┐ + │ MCP Client │ │ HTTP Server │ + │ │ │ localhost:port │ + │ Bearer Token Auth │ │ OAuth Callback │ + │ StreamableClient │ │ │ + │ SSEClient │ │ │ + └────────────────────┘ └─────────────────┘ +``` + ## Usage 1. Go to **Settings → MCP Servers** @@ -78,13 +117,14 @@ On non-web platforms: - **PKCE Protection**: Prevents authorization code interception attacks - **State Parameter Validation**: Prevents CSRF attacks -- **Origin Validation**: Ensures callbacks come from expected sources -- **Browser Extension Filtering**: Ignores interference from development tools +- **Origin Validation**: Ensures callbacks come from expected sources (web) +- **Local Server Security**: Uses localhost-only binding for OAuth callbacks (desktop) +- **Browser Extension Filtering**: Ignores interference from development tools (web) - **Token Expiry Handling**: Automatic refresh before expiration ## Future Enhancements -- Mobile/Desktop OAuth support via external browser +- Mobile OAuth support via external browser - Additional OAuth flows (device code, etc.) - OAuth provider-specific optimizations - Enhanced error handling and user feedback diff --git a/lib/provider/mcp_server_provider.dart b/lib/provider/mcp_server_provider.dart index 268490d4..25badf6c 100644 --- a/lib/provider/mcp_server_provider.dart +++ b/lib/provider/mcp_server_provider.dart @@ -10,7 +10,7 @@ import 'package:chatmcp/utils/storage_manager.dart'; import '../mcp/client/mcp_client_interface.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../mcp/models/server.dart'; -import '../utils/oauth_web.dart' if (dart.library.io) '../utils/oauth_stub.dart'; +import '../utils/oauth_web.dart' if (dart.library.io) '../utils/oauth_desktop.dart'; import '../utils/oauth_discovery.dart'; var defaultInMemoryServers = [ @@ -437,10 +437,7 @@ class McpServerProvider extends ChangeNotifier { /// Discovers OAuth configuration for a server URL automatically Future discoverOAuthForServer(String serverUrl) async { - if (!kIsWeb) { - return OAuthDiscoveryResult(requiresOAuth: false); - } - + // OAuth discovery works on all platforms now (web and desktop) try { Logger.root.info('Discovering OAuth for server: $serverUrl'); final result = await OAuthDiscoveryService.discoverOAuth(serverUrl); @@ -463,7 +460,7 @@ class McpServerProvider extends ChangeNotifier { /// Automatically configures and authenticates a server with discovered OAuth Future autoAuthenticateServer(String serverName, OAuthDiscoveryResult oauthConfig) async { - if (!kIsWeb || !oauthConfig.requiresOAuth) { + if (!oauthConfig.requiresOAuth) { return false; } @@ -473,7 +470,7 @@ class McpServerProvider extends ChangeNotifier { // Use discovered client ID or null for public clients (like Notion MCP) String? clientId = oauthConfig.clientId; String scope = oauthConfig.scope ?? 'read write'; - String redirectUri = oauthConfig.redirectUri ?? '${Uri.base.origin}/oauth_callback.html'; + String redirectUri = oauthConfig.redirectUri ?? 'http://localhost:0'; Logger.root.info('Using clientId: $clientId, scope: $scope, redirectUri: $redirectUri'); @@ -485,6 +482,10 @@ class McpServerProvider extends ChangeNotifier { scope: scope, ); + // Use the actual redirect URI from the auth result if available (desktop) + // or fall back to the configured redirect URI (web) + final actualRedirectUri = authResult['redirect_uri'] as String? ?? redirectUri; + // Exchange code for token final tokenResult = await WebOAuthHandler.exchangeCodeForToken( tokenUrl: oauthConfig.tokenUrl!, @@ -492,7 +493,7 @@ class McpServerProvider extends ChangeNotifier { clientSecret: null, // Public clients don't require client secret code: authResult['code'] as String, codeVerifier: authResult['code_verifier'] as String, - redirectUri: redirectUri, + redirectUri: actualRedirectUri, ); // Update server configuration with OAuth info and tokens @@ -564,10 +565,6 @@ class McpServerProvider extends ChangeNotifier { throw Exception('OAuth not enabled for server: $serverName'); } - if (!kIsWeb) { - throw Exception('OAuth is only supported on web platform'); - } - // Extract OAuth parameters with debugging final authorizationUrl = oauth['authorization_url'] as String? ?? ''; final clientId = oauth['client_id'] as String? ?? ''; @@ -588,6 +585,9 @@ class McpServerProvider extends ChangeNotifier { scope: scope, ); + // Use the actual redirect URI from the auth result if available (desktop) + final actualRedirectUri = authResult['redirect_uri'] as String? ?? redirectUri; + // Exchange code for token final tokenResult = await WebOAuthHandler.exchangeCodeForToken( tokenUrl: oauth['token_url'] as String, @@ -595,7 +595,7 @@ class McpServerProvider extends ChangeNotifier { clientSecret: oauth['client_secret'] as String?, code: authResult['code'] as String, codeVerifier: authResult['code_verifier'] as String, - redirectUri: oauth['redirect_uri'] as String, + redirectUri: actualRedirectUri, ); // Update server config with tokens @@ -640,10 +640,6 @@ class McpServerProvider extends ChangeNotifier { throw Exception('No refresh token available for server: $serverName'); } - if (!kIsWeb) { - throw Exception('OAuth is only supported on web platform'); - } - // Refresh token final tokenResult = await WebOAuthHandler.refreshToken( tokenUrl: oauth['token_url'] as String, diff --git a/lib/utils/oauth_desktop.dart b/lib/utils/oauth_desktop.dart new file mode 100644 index 00000000..b6d02d02 --- /dev/null +++ b/lib/utils/oauth_desktop.dart @@ -0,0 +1,411 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:crypto/crypto.dart'; +import 'package:logging/logging.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:shelf/shelf_io.dart' as shelf_io; + +/// Desktop-based OAuth 2.0 + PKCE handler for MCP servers +/// +/// Handles OAuth authorization flows in desktop environments using external browser +/// and local HTTP server for callback handling. Supports both public clients (no client_id) +/// and confidential clients with PKCE (RFC 7636) for security. +/// +/// Note: This implementation does not support concurrent OAuth flows. Only one +/// OAuth flow can be active at a time. +class WebOAuthHandler { + static const String _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; + static final Random _rng = Random(); + static HttpServer? _callbackServer; + static Completer>? _callbackCompleter; + static int? _lastCallbackPort; + + /// Default fallback client ID used by some implementations + static const String _fallbackClientId = 'mcp-client'; + + /// Generates a random string for PKCE code verifier + static String _generateRandomString(int length) { + return String.fromCharCodes( + Iterable.generate(length, (_) => _chars.codeUnitAt(_rng.nextInt(_chars.length))), + ); + } + + /// Generates PKCE code challenge from verifier + static String _generateCodeChallenge(String codeVerifier) { + final bytes = utf8.encode(codeVerifier); + final digest = sha256.convert(bytes); + return base64Url.encode(digest.bytes).replaceAll('=', ''); + } + + /// Starts a local HTTP server to handle OAuth callback + static Future _startCallbackServer(String expectedState) async { + // Find an available port + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + _callbackServer = server; + final port = server.port; + _lastCallbackPort = port; + + Logger.root.info('OAuth callback server started on port $port'); + + // Create a shelf handler + final handler = shelf.Pipeline() + .addMiddleware(shelf.logRequests()) + .addHandler((shelf.Request request) async { + + // Only handle GET requests to the root path + if (request.method != 'GET' || request.url.path != '') { + return shelf.Response.notFound('Not Found'); + } + + final params = request.url.queryParameters; + Logger.root.info('Received OAuth callback with params: $params'); + + // Check for error + if (params.containsKey('error')) { + final error = params['error']; + final errorDescription = params['error_description'] ?? ''; + Logger.root.severe('OAuth error: $error - $errorDescription'); + + if (_callbackCompleter != null && !_callbackCompleter!.isCompleted) { + _callbackCompleter!.completeError( + Exception('OAuth error: $error - $errorDescription') + ); + } + + return shelf.Response.ok( + ''' + + Authentication Error + +

Authentication Error

+

Error: $error

+

$errorDescription

+

You can close this window.

+ + + ''', + headers: {'Content-Type': 'text/html'}, + ); + } + + // Verify state parameter + final state = params['state']; + if (state != expectedState) { + Logger.root.severe('State mismatch - expected: $expectedState, got: $state'); + + if (_callbackCompleter != null && !_callbackCompleter!.isCompleted) { + _callbackCompleter!.completeError( + Exception('Invalid state parameter - possible CSRF attack') + ); + } + + return shelf.Response.ok( + ''' + + Authentication Error + +

Authentication Error

+

Invalid state parameter. Please try again.

+

You can close this window.

+ + + ''', + headers: {'Content-Type': 'text/html'}, + ); + } + + // Extract authorization code + final code = params['code']; + if (code == null) { + Logger.root.severe('No authorization code received'); + + if (_callbackCompleter != null && !_callbackCompleter!.isCompleted) { + _callbackCompleter!.completeError( + Exception('No authorization code received') + ); + } + + return shelf.Response.ok( + ''' + + Authentication Error + +

Authentication Error

+

No authorization code received.

+

You can close this window.

+ + + ''', + headers: {'Content-Type': 'text/html'}, + ); + } + + // Success - complete the callback + Logger.root.info('Authorization code received successfully'); + + if (_callbackCompleter != null && !_callbackCompleter!.isCompleted) { + _callbackCompleter!.complete({ + 'code': code, + 'state': state, + }); + } + + return shelf.Response.ok( + ''' + + Authentication Successful + +

Authentication Successful!

+

You have been successfully authenticated.

+

You can close this window and return to the application.

+ + + + ''', + headers: {'Content-Type': 'text/html'}, + ); + }); + + // Serve requests + shelf_io.serveRequests(server, handler); + + return port; + } + + /// Stops the callback server + static Future _stopCallbackServer() async { + if (_callbackServer != null) { + await _callbackServer!.close(force: true); + _callbackServer = null; + Logger.root.info('OAuth callback server stopped'); + } + } + + /// Starts OAuth flow with Authorization Code + PKCE + static Future> startOAuthFlow({ + required String authorizationUrl, + String? clientId, + required String redirectUri, + required String scope, + String? state, + }) async { + // Ensure no other OAuth flow is in progress + if (_callbackServer != null) { + throw Exception('Another OAuth flow is already in progress. Please wait for it to complete.'); + } + + try { + // Debug log the parameters + Logger.root.info('OAuth Parameters:'); + Logger.root.info(' authorizationUrl: $authorizationUrl'); + Logger.root.info(' clientId: $clientId'); + Logger.root.info(' redirectUri: $redirectUri'); + Logger.root.info(' scope: $scope'); + + // Generate PKCE parameters + final codeVerifier = _generateRandomString(128); + final codeChallenge = _generateCodeChallenge(codeVerifier); + final stateParam = state ?? _generateRandomString(32); + + // Start local callback server + final callbackPort = await _startCallbackServer(stateParam); + final localRedirectUri = 'http://localhost:$callbackPort'; + + Logger.root.info('Using local redirect URI: $localRedirectUri'); + + // Build authorization URL + final authUri = Uri.parse(authorizationUrl).replace(queryParameters: { + 'response_type': 'code', + 'redirect_uri': localRedirectUri, + 'scope': scope, + 'state': stateParam, + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256', + // Only include client_id if provided (some servers support public clients) + if (clientId != null && clientId.isNotEmpty) 'client_id': clientId, + }); + + Logger.root.info('Starting OAuth flow with URL: $authUri'); + + // Open browser for authorization + final url = Uri.parse(authUri.toString()); + if (await canLaunchUrl(url)) { + final launched = await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + throw Exception('Failed to launch browser for OAuth authorization'); + } + } else { + throw Exception('Cannot launch URL: $authUri'); + } + + // Wait for callback + _callbackCompleter = Completer>(); + + try { + final result = await _callbackCompleter!.future.timeout( + const Duration(minutes: 10), + onTimeout: () { + throw Exception('OAuth flow timed out - user did not complete authorization'); + }, + ); + + // Store the port before stopping the server + final callbackPort = _lastCallbackPort; + if (callbackPort == null) { + throw Exception('Failed to track callback server port'); + } + + await _stopCallbackServer(); + + return { + 'code': result['code']!, + 'code_verifier': codeVerifier, + 'state': result['state']!, + 'redirect_uri': 'http://localhost:$callbackPort', // Include actual redirect URI used + }; + } catch (e) { + // Make sure to stop the server on error + await _stopCallbackServer(); + rethrow; + } + } catch (e) { + Logger.root.severe('OAuth flow failed: $e'); + await _stopCallbackServer(); + rethrow; + } + } + + /// Exchanges authorization code for access token + static Future> exchangeCodeForToken({ + required String tokenUrl, + String? clientId, + String? clientSecret, + required String code, + required String codeVerifier, + required String redirectUri, + }) async { + try { + final headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }; + + final body = { + 'grant_type': 'authorization_code', + 'code': code, + 'code_verifier': codeVerifier, + 'redirect_uri': redirectUri, // Use the redirect_uri from the flow result + }; + + // Only include client_id if it's provided and meaningful + // Some OAuth servers (like Notion MCP) work with public clients (no client_id) + // We exclude the fallback client ID as it's used as a placeholder by some implementations + // and should not be sent to the OAuth server + if (clientId != null && clientId.isNotEmpty && clientId != _fallbackClientId) { + body['client_id'] = clientId; + } + + if (clientSecret != null && clientSecret.isNotEmpty) { + body['client_secret'] = clientSecret; + } + + Logger.root.info('Exchanging code for token at: $tokenUrl'); + + final response = await http.post( + Uri.parse(tokenUrl), + headers: headers, + body: body.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&'), + ); + + if (response.statusCode == 200) { + final tokenData = json.decode(response.body) as Map; + + // Calculate token expiry if expires_in is provided + if (tokenData['expires_in'] != null) { + final expiresIn = tokenData['expires_in'] as int; + tokenData['expires_at'] = DateTime.now().add(Duration(seconds: expiresIn)).toIso8601String(); + } + + Logger.root.info('Token exchange successful'); + return tokenData; + } else { + final errorBody = response.body; + Logger.root.severe('Token exchange failed: ${response.statusCode} - $errorBody'); + throw Exception('Token exchange failed: ${response.statusCode} - $errorBody'); + } + } catch (e) { + Logger.root.severe('Token exchange error: $e'); + rethrow; + } + } + + /// Refreshes an expired access token + static Future> refreshToken({ + required String tokenUrl, + String? clientId, + String? clientSecret, + required String refreshToken, + }) async { + try { + final headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }; + + final body = { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + }; + + // Only include client_id if provided + if (clientId != null && clientId.isNotEmpty) { + body['client_id'] = clientId; + } + + if (clientSecret != null && clientSecret.isNotEmpty) { + body['client_secret'] = clientSecret; + } + + Logger.root.info('Refreshing token at: $tokenUrl'); + + final response = await http.post( + Uri.parse(tokenUrl), + headers: headers, + body: body.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&'), + ); + + if (response.statusCode == 200) { + final tokenData = json.decode(response.body) as Map; + + // Calculate token expiry if expires_in is provided + if (tokenData['expires_in'] != null) { + final expiresIn = tokenData['expires_in'] as int; + tokenData['expires_at'] = DateTime.now().add(Duration(seconds: expiresIn)).toIso8601String(); + } + + Logger.root.info('Token refresh successful'); + return tokenData; + } else { + final errorBody = response.body; + Logger.root.severe('Token refresh failed: ${response.statusCode} - $errorBody'); + throw Exception('Token refresh failed: ${response.statusCode} - $errorBody'); + } + } catch (e) { + Logger.root.severe('Token refresh error: $e'); + rethrow; + } + } +} diff --git a/lib/utils/oauth_discovery.dart b/lib/utils/oauth_discovery.dart index 73657f5e..c88f4f84 100644 --- a/lib/utils/oauth_discovery.dart +++ b/lib/utils/oauth_discovery.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; /// Result of OAuth discovery for an MCP server class OAuthDiscoveryResult { @@ -222,9 +223,17 @@ class OAuthDiscoveryService { /// Generate appropriate redirect URI for current platform static String _generateRedirectUri() { - // Get current page URL and construct redirect URI - final currentUrl = Uri.base; - return '${currentUrl.origin}/oauth_callback.html'; + if (kIsWeb) { + // Web platform: use the current page origin with oauth_callback.html + final currentUrl = Uri.base; + return '${currentUrl.origin}/oauth_callback.html'; + } else { + // Desktop/Mobile platform: placeholder for localhost redirect + // The actual port will be determined dynamically by the OAuth handler + // when it starts the local callback server. This value is not used directly + // but serves as a template that the handler will replace. + return 'http://localhost:0'; + } } /// Try dynamic client registration (RFC 7591) @@ -238,7 +247,7 @@ class OAuthDiscoveryService { 'grant_types': ['authorization_code', 'refresh_token'], 'response_types': ['code'], 'token_endpoint_auth_method': 'none', // Public client - 'application_type': 'web', + 'application_type': kIsWeb ? 'web' : 'native', 'scope': 'read write', }; diff --git a/lib/utils/oauth_web.dart b/lib/utils/oauth_web.dart index 5f58f9b0..09a8bd83 100644 --- a/lib/utils/oauth_web.dart +++ b/lib/utils/oauth_web.dart @@ -95,6 +95,7 @@ class WebOAuthHandler { 'code': code, 'code_verifier': codeVerifier, 'state': result['state'], + 'redirect_uri': redirectUri, // Include redirect URI for consistency with desktop }; } catch (e) { timer.cancel();