diff --git a/docs/mcp_oauth_servers.md b/docs/mcp_oauth_servers.md index 8c75d7a4..d1e213a0 100644 --- a/docs/mcp_oauth_servers.md +++ b/docs/mcp_oauth_servers.md @@ -14,12 +14,26 @@ This feature adds automatic OAuth 2.0 authentication support for remote MCP serv ## 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 (Windows/macOS/Linux)**: Full OAuth support with system browser-based authentication flow +**❌ Mobile (iOS/Android)**: OAuth authentication is not yet supported on mobile platforms -On non-web platforms: +### Platform-Specific Implementation + +#### Web Platform +- Opens OAuth authorization in a popup window +- Uses cross-origin messaging for callback handling +- Returns to the same web application seamlessly + +#### Desktop Platform (Windows, macOS, Linux) +- Opens OAuth authorization in the system's default browser +- Runs a local HTTP server on `localhost:8080` to receive the callback +- Displays success/error page in browser after authentication +- Automatically closes the browser window (or prompts user to close) + +#### Mobile Platform - OAuth discovery still works (detects requirements) -- OAuth authentication throws `UnsupportedError` -- Fallback to manual configuration or other auth methods +- OAuth authentication is not yet implemented +- Future enhancement planned for mobile OAuth support ## Tested OAuth Providers @@ -35,9 +49,10 @@ On non-web platforms: - Falls back to public client mode if no client registration 2. **Authentication Phase**: - - Opens OAuth authorization popup - - Handles PKCE code challenge/verifier generation - - Processes OAuth callback with state validation + - **Web**: Opens OAuth authorization in a popup window + - **Desktop**: Opens OAuth authorization in the system's default browser + - Handles PKCE code challenge/verifier generation for security + - Processes OAuth callback with state validation to prevent CSRF attacks - Exchanges authorization code for access token 3. **Usage Phase**: @@ -47,10 +62,11 @@ On non-web platforms: ## Architecture +### Web Platform ``` ┌─────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │ MCP Server │ │ OAuth Discovery │ │ OAuth Handler │ -│ │◄──►│ │◄──►│ │ +│ │◄──►│ │◄──►│ (Web/Popup) │ │ /.well-known/ │ │ RFC 8414 Compliant │ │ PKCE + Popup │ │ oauth-auth... │ │ │ │ Cross-origin │ └─────────────────┘ └────────────────────┘ └─────────────────┘ @@ -65,30 +81,81 @@ On non-web platforms: └────────────────────┘ ``` +### Desktop Platform (Windows/macOS/Linux) +``` +┌─────────────────┐ ┌────────────────────┐ ┌─────────────────┐ +│ MCP Server │ │ OAuth Discovery │ │ OAuth Handler │ +│ │◄──►│ │◄──►│ (Desktop) │ +│ /.well-known/ │ │ RFC 8414 Compliant │ │ PKCE + Browser │ +│ oauth-auth... │ │ │ │ Local Server │ +└─────────────────┘ └────────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌────────────────────┐ ┌─────────────────┐ + │ MCP Client │ │ System Browser │ + │ │ │ │ + │ Bearer Token Auth │ │ localhost:8080 │ + │ StreamableClient │ │ Callback Server │ + │ SSEClient │ │ │ + └────────────────────┘ └─────────────────┘ +``` + ## Usage +### Web Platform 1. Go to **Settings → MCP Servers** -2. Enter an OAuth-protected MCP server URL (e.g., `https://mcp.notion.com/mcp`) +2. Enter an OAuth-protected MCP server URL (e.g., `https://mcp.atlassian.com/v1/mcp`) 3. Click **Add Server** - OAuth requirements are detected automatically 4. If OAuth is required, you'll be prompted to authenticate 5. Complete the OAuth flow in the popup window 6. The server will be ready to use with automatic token authentication +### Desktop Platform (Windows/macOS/Linux) +1. Go to **Settings → MCP Servers** +2. Enter an OAuth-protected MCP server URL (e.g., `https://mcp.atlassian.com/v1/mcp`) +3. Click **Add Server** - OAuth requirements are detected automatically +4. If OAuth is required, you'll be prompted to authenticate +5. Your default browser will open to the OAuth provider's authorization page +6. After granting permission, you'll see a success page in the browser +7. Return to ChatMCP - the server will be ready to use with automatic token authentication + ## Security Features - **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) +- **Browser Extension Filtering**: Ignores interference from development tools (web) +- **Localhost Binding**: Local callback server binds only to localhost for security (desktop) - **Token Expiry Handling**: Automatic refresh before expiration ## Future Enhancements -- Mobile/Desktop OAuth support via external browser -- Additional OAuth flows (device code, etc.) +- ✅ ~~Desktop OAuth support via external browser~~ (Implemented in this version) +- Mobile OAuth support (iOS/Android) +- Additional OAuth flows (device code, client credentials, etc.) - OAuth provider-specific optimizations - Enhanced error handling and user feedback +- Configurable callback server port for desktop --- -This implementation follows OAuth 2.0 security best practices and modern web standards for a robust, user-friendly authentication experience. +## Implementation Notes + +### Platform-Specific OAuth Handlers + +The OAuth implementation uses Dart's conditional imports to provide platform-specific handlers: + +- **Web Platform** (`oauth_web.dart`): Uses popup windows and `postMessage` API for callback handling +- **Desktop Platform** (`oauth_io.dart`): Uses `url_launcher` to open system browser and runs a local HTTP server on `localhost:8080` for callbacks +- **Stub** (`oauth_stub.dart`): Placeholder for unsupported platforms (currently mobile) + +### File Structure + +- `lib/utils/oauth_discovery.dart`: Platform-agnostic OAuth discovery service (RFC 8414, RFC 7591) +- `lib/utils/oauth_web.dart`: Web-specific OAuth handler +- `lib/utils/oauth_io.dart`: Desktop-specific OAuth handler (Windows/macOS/Linux) +- `lib/utils/oauth_stub.dart`: Stub for unsupported platforms +- `lib/provider/mcp_server_provider.dart`: OAuth integration with MCP server management +- `web/oauth_callback.html`: OAuth callback page for web platform + +This implementation follows OAuth 2.0 security best practices and modern web standards for a robust, user-friendly authentication experience across multiple platforms. diff --git a/lib/provider/mcp_server_provider.dart b/lib/provider/mcp_server_provider.dart index 268490d4..8c3d16b4 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_io.dart'; import '../utils/oauth_discovery.dart'; var defaultInMemoryServers = [ @@ -435,12 +435,18 @@ class McpServerProvider extends ChangeNotifier { // OAuth-related methods + /// Gets the default redirect URI for the current platform + String _getDefaultRedirectUri() { + if (kIsWeb) { + return '${Uri.base.origin}/oauth_callback.html'; + } else { + // For desktop platforms, use localhost with a default port + return 'http://localhost:8080/oauth/callback'; + } + } + /// Discovers OAuth configuration for a server URL automatically Future discoverOAuthForServer(String serverUrl) async { - if (!kIsWeb) { - return OAuthDiscoveryResult(requiresOAuth: false); - } - try { Logger.root.info('Discovering OAuth for server: $serverUrl'); final result = await OAuthDiscoveryService.discoverOAuth(serverUrl); @@ -463,7 +469,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 +479,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 ?? _getDefaultRedirectUri(); Logger.root.info('Using clientId: $clientId, scope: $scope, redirectUri: $redirectUri'); @@ -564,10 +570,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? ?? ''; @@ -640,10 +642,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_discovery.dart b/lib/utils/oauth_discovery.dart index 73657f5e..4c396421 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,14 @@ 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 current page origin + final currentUrl = Uri.base; + return '${currentUrl.origin}/oauth_callback.html'; + } else { + // Desktop platforms: use localhost with default port + return 'http://localhost:8080/oauth/callback'; + } } /// Try dynamic client registration (RFC 7591) diff --git a/lib/utils/oauth_io.dart b/lib/utils/oauth_io.dart new file mode 100644 index 00000000..2b64ce4d --- /dev/null +++ b/lib/utils/oauth_io.dart @@ -0,0 +1,513 @@ +/// Desktop OAuth 2.0 Implementation for MCP Servers +/// +/// This file provides OAuth 2.0 authentication support for desktop platforms +/// (Windows, macOS, Linux) in ChatMCP. It is automatically used via conditional +/// imports when running on non-web platforms that support dart:io. +/// +/// ## OAuth Flow Overview: +/// 1. User initiates OAuth authentication for an MCP server +/// 2. A local HTTP server starts on localhost:8080 (or any available port) +/// 3. System browser opens to the OAuth provider's authorization page +/// 4. User grants permission on the OAuth provider's website +/// 5. OAuth provider redirects back to http://localhost:/oauth/callback +/// 6. Local server receives the authorization code and validates state parameter +/// 7. Authorization code is exchanged for access token via HTTPS +/// 8. Browser displays success page, local server shuts down +/// 9. Access token is stored and used for subsequent MCP requests +/// +/// ## Security Features: +/// - PKCE (Proof Key for Code Exchange) - RFC 7636 +/// - State parameter validation to prevent CSRF attacks +/// - Localhost-only binding for callback server +/// - Automatic cleanup of callback server after authentication +/// - Support for both public and confidential clients +/// +/// ## Platform Support: +/// - ✅ Windows +/// - ✅ macOS +/// - ✅ Linux +/// - ❌ Mobile (iOS/Android) - use oauth_stub.dart instead +/// +/// ## Dependencies: +/// - url_launcher: Opens system browser for OAuth authorization +/// - dart:io: Runs local HTTP server for OAuth callbacks +/// - crypto: Generates PKCE code challenges (SHA-256) +library; + +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'; + +/// Desktop (IO) OAuth 2.0 + PKCE handler for MCP servers +/// +/// Handles OAuth authorization flows on desktop platforms (Windows, macOS, Linux) +/// by opening the system browser for authorization and running a local HTTP server +/// to receive the callback. Supports both public clients (no client_id) and +/// confidential clients with PKCE (RFC 7636) for security. +/// +/// Note: This implementation is designed for desktop platforms. Mobile platforms +/// should use a different OAuth implementation due to differences in how localhost +/// callbacks work on mobile devices. +class WebOAuthHandler { + static const String _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; + static final Random _rng = Random(); + static final Logger _logger = Logger('WebOAuthHandler'); + + /// Default fallback client ID for public clients + static const String _defaultClientId = 'mcp-client'; + + /// Check if current platform is supported (desktop only) + static bool _isSupportedPlatform() { + return Platform.isWindows || Platform.isMacOS || Platform.isLinux; + } + + /// 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 OAuth flow with Authorization Code + PKCE + static Future> startOAuthFlow({ + required String authorizationUrl, + String? clientId, + required String redirectUri, + required String scope, + String? state, + }) async { + if (!_isSupportedPlatform()) { + throw UnsupportedError('OAuth is not yet supported on mobile platforms'); + } + + try { + _logger.info('Starting desktop OAuth flow'); + _logger.info(' authorizationUrl: $authorizationUrl'); + _logger.info(' clientId: $clientId'); + _logger.info(' redirectUri: $redirectUri'); + _logger.info(' scope: $scope'); + + // Generate PKCE parameters + final codeVerifier = _generateRandomString(128); + final codeChallenge = _generateCodeChallenge(codeVerifier); + final stateParam = state ?? _generateRandomString(32); + + // Parse redirect URI to get port for local server + final redirectUriParsed = Uri.parse(redirectUri); + final port = redirectUriParsed.port; + + // Start local HTTP server to receive callback + final callbackServer = await _CallbackServer.start(port, stateParam); + + try { + // Use the actual server port in case it differs from requested port + final actualRedirectUri = redirectUri.replaceAll(':$port/', ':${callbackServer.actualPort}/'); + + _logger.info('Using redirect URI: $actualRedirectUri'); + + // Build authorization URL + final authUri = Uri.parse(authorizationUrl).replace(queryParameters: { + 'response_type': 'code', + 'redirect_uri': actualRedirectUri, + 'scope': scope, + 'state': stateParam, + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256', + if (clientId != null && clientId.isNotEmpty) 'client_id': clientId, + }); + + _logger.info('Opening browser for authorization: $authUri'); + + // Open system browser for authorization + final launched = await launchUrl( + authUri, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + throw Exception('Failed to open browser for OAuth authorization'); + } + + // Wait for callback + _logger.info('Waiting for OAuth callback...'); + final result = await callbackServer.waitForCallback(); + + if (result['error'] != null) { + throw Exception('OAuth error: ${result['error']} - ${result['error_description'] ?? ''}'); + } + + final code = result['code']; + if (code == null) { + throw Exception('Authorization code not received'); + } + + return { + 'code': code, + 'code_verifier': codeVerifier, + 'state': result['state'], + }; + } finally { + await callbackServer.close(); + } + } catch (e) { + _logger.severe('OAuth flow failed: $e'); + 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 { + if (!_isSupportedPlatform()) { + throw UnsupportedError('OAuth is not yet supported on mobile platforms'); + } + + try { + final headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }; + + final body = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirectUri, + 'code_verifier': codeVerifier, + }; + + // Only include client_id if it's provided and not the default fallback + if (clientId != null && clientId.isNotEmpty && clientId != _defaultClientId) { + body['client_id'] = clientId; + } + + if (clientSecret != null && clientSecret.isNotEmpty) { + body['client_secret'] = clientSecret; + } + + _logger.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.info('Token exchange successful'); + return tokenData; + } else { + final errorBody = response.body; + _logger.severe('Token exchange failed: ${response.statusCode} - $errorBody'); + throw Exception('Token exchange failed: ${response.statusCode} - $errorBody'); + } + } catch (e) { + _logger.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 { + if (!_isSupportedPlatform()) { + throw UnsupportedError('OAuth is not yet supported on mobile platforms'); + } + + 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.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.info('Token refresh successful'); + return tokenData; + } else { + final errorBody = response.body; + _logger.severe('Token refresh failed: ${response.statusCode} - $errorBody'); + throw Exception('Token refresh failed: ${response.statusCode} - $errorBody'); + } + } catch (e) { + _logger.severe('Token refresh error: $e'); + rethrow; + } + } +} + +/// Local HTTP server to handle OAuth callbacks on desktop platforms +class _CallbackServer { + final HttpServer _server; + final String _expectedState; + final Completer> _completer = Completer>(); + + _CallbackServer._(this._server, this._expectedState) { + _server.listen(_handleRequest); + } + + /// Gets the actual port the server is listening on + int get actualPort => _server.port; + + /// Starts a local HTTP server on the specified port + static Future<_CallbackServer> start(int port, String expectedState) async { + try { + // Try to bind to the specified port, fallback to any available port + HttpServer server; + try { + server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + } catch (e) { + Logger.root.warning('Failed to bind to port $port, using any available port: $e'); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + } + + Logger.root.info('OAuth callback server started on http://localhost:${server.port}'); + return _CallbackServer._(server, expectedState); + } catch (e) { + Logger.root.severe('Failed to start OAuth callback server: $e'); + rethrow; + } + } + + /// Handles incoming HTTP requests + void _handleRequest(HttpRequest request) async { + try { + Logger.root.info('Received OAuth callback: ${request.uri}'); + + // Extract parameters from query string + final params = request.uri.queryParameters; + final code = params['code']; + final state = params['state']; + final error = params['error']; + final errorDescription = params['error_description']; + + // Prepare response HTML + String responseHtml; + if (error != null) { + responseHtml = _buildErrorHtml(error, errorDescription); + _completer.completeError(Exception('OAuth error: $error - ${errorDescription ?? ''}')); + } else if (state == null || state != _expectedState) { + responseHtml = _buildErrorHtml('invalid_state', 'State parameter missing or mismatch'); + _completer.completeError(Exception('Invalid state parameter')); + } else if (code != null) { + responseHtml = _buildSuccessHtml(); + _completer.complete({ + 'code': code, + 'state': state, + }); + } else { + responseHtml = _buildErrorHtml('invalid_request', 'No authorization code or state received'); + _completer.completeError(Exception('No authorization code or state received')); + } + + // Send response + request.response + ..statusCode = 200 + ..headers.contentType = ContentType.html + ..write(responseHtml); + await request.response.close(); + + } catch (e) { + Logger.root.severe('Error handling OAuth callback: $e'); + request.response + ..statusCode = 500 + ..write('Internal server error'); + await request.response.close(); + } + } + + /// Waits for the OAuth callback to complete + Future> waitForCallback() { + return _completer.future.timeout( + const Duration(minutes: 10), + onTimeout: () { + throw Exception('OAuth flow timed out'); + }, + ); + } + + /// Closes the callback server + Future close() async { + await _server.close(); + Logger.root.info('OAuth callback server closed'); + } + + /// Builds success HTML response + String _buildSuccessHtml() { + return ''' + + + + Authentication Successful + + + + +
+
+

Authentication Successful

+

You have successfully authenticated with the OAuth provider.

+

You can close this window and return to ChatMCP.

+
+ + + +'''; + } + + /// Builds error HTML response + String _buildErrorHtml(String error, String? errorDescription) { + return ''' + + + + Authentication Failed + + + + +
+
+

Authentication Failed

+

There was a problem authenticating with the OAuth provider.

+
+ Error: $error + ${errorDescription != null ? '
Description: $errorDescription' : ''} +
+

You can close this window and try again.

+
+ + +'''; + } +} diff --git a/lib/utils/oauth_stub.dart b/lib/utils/oauth_stub.dart index a35586ce..c47cc5e9 100644 --- a/lib/utils/oauth_stub.dart +++ b/lib/utils/oauth_stub.dart @@ -1,4 +1,6 @@ -// Stub for non-web platforms +// Stub for mobile platforms (iOS/Android) +// Desktop platforms (Windows/macOS/Linux) use oauth_io.dart +// Web platform uses oauth_web.dart class WebOAuthHandler { static Future> startOAuthFlow({ required String authorizationUrl, @@ -7,7 +9,7 @@ class WebOAuthHandler { required String scope, String? state, }) async { - throw UnsupportedError('OAuth is only supported on web platform'); + throw UnsupportedError('OAuth is not yet supported on mobile platforms'); } static Future> exchangeCodeForToken({ @@ -18,7 +20,7 @@ class WebOAuthHandler { required String codeVerifier, required String redirectUri, }) async { - throw UnsupportedError('OAuth is only supported on web platform'); + throw UnsupportedError('OAuth is not yet supported on mobile platforms'); } static Future> refreshToken({ @@ -27,6 +29,6 @@ class WebOAuthHandler { String? clientSecret, required String refreshToken, }) async { - throw UnsupportedError('OAuth is only supported on web platform'); + throw UnsupportedError('OAuth is not yet supported on mobile platforms'); } }