mirror of
https://github.com/foss42/apidash.git
synced 2025-12-02 18:57:05 +08:00
feat: implement local http server in desktop platforms for oauth2
This commit is contained in:
@@ -150,18 +150,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_web_auth_2
|
||||
sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696"
|
||||
sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "5.0.0-alpha.3"
|
||||
flutter_web_auth_2_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_web_auth_2_platform_interface
|
||||
sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d
|
||||
sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "5.0.0-alpha.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'dart:developer' show log;
|
||||
|
||||
/// A lightweight HTTP server for handling OAuth callbacks on desktop platforms.
|
||||
/// This provides a standard localhost callback URL that's compatible with most OAuth providers.
|
||||
class OAuthCallbackServer {
|
||||
HttpServer? _server;
|
||||
late int _port;
|
||||
String? _path;
|
||||
final Completer<String> _completer = Completer<String>();
|
||||
Timer? _timeoutTimer;
|
||||
Timer? _heartbeatTimer;
|
||||
DateTime? _lastHeartbeat;
|
||||
bool _browserTabActive = false;
|
||||
|
||||
/// Starts the HTTP server and returns the callback URL.
|
||||
///
|
||||
/// [path] - Optional path for the callback endpoint (defaults to '/callback')
|
||||
/// Returns the full callback URL (e.g., 'http://localhost:8080/callback')
|
||||
Future<String> start({String path = '/callback'}) async {
|
||||
_path = path;
|
||||
|
||||
// Try to bind to a random available port starting from 8080
|
||||
for (int port = 8080; port <= 8090; port++) {
|
||||
try {
|
||||
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
|
||||
_port = port;
|
||||
break;
|
||||
} catch (e) {
|
||||
// Port is busy, try next one
|
||||
if (port == 8090) {
|
||||
throw Exception(
|
||||
'Unable to find available port for OAuth callback server',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_server == null) {
|
||||
throw Exception('Failed to start OAuth callback server');
|
||||
}
|
||||
|
||||
_server!.listen(_handleRequest);
|
||||
|
||||
final callbackUrl = 'http://localhost:$_port$_path';
|
||||
log('OAuth callback server started at: $callbackUrl');
|
||||
|
||||
return callbackUrl;
|
||||
}
|
||||
|
||||
/// Waits for the OAuth callback and returns the full callback URL with query parameters.
|
||||
///
|
||||
/// [timeout] - Optional timeout duration (defaults to 3 minutes)
|
||||
/// Throws [TimeoutException] if no callback is received within the timeout period.
|
||||
/// Throws [Exception] if the browser tab is closed without completing authorization.
|
||||
Future<String> waitForCallback({
|
||||
Duration timeout = const Duration(minutes: 3),
|
||||
}) async {
|
||||
// Set up timeout timer
|
||||
_timeoutTimer = Timer(timeout, () {
|
||||
if (!_completer.isCompleted) {
|
||||
_completer.completeError(
|
||||
TimeoutException(
|
||||
'OAuth callback timeout: No response received within ${timeout.inMinutes} minutes. '
|
||||
'The user may have closed the browser tab without completing authorization.',
|
||||
timeout,
|
||||
),
|
||||
);
|
||||
|
||||
// Automatically stop the server on timeout
|
||||
_stopServerOnError(
|
||||
'OAuth flow timed out after ${timeout.inMinutes} minutes',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up heartbeat monitoring to detect if browser tab is closed
|
||||
_startHeartbeatMonitoring();
|
||||
|
||||
try {
|
||||
return await _completer.future;
|
||||
} finally {
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = null;
|
||||
_stopHeartbeatMonitoring();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the HTTP server and cleans up resources.
|
||||
Future<void> stop() async {
|
||||
if (_server == null) {
|
||||
log('OAuth callback server already stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = null;
|
||||
_stopHeartbeatMonitoring();
|
||||
|
||||
try {
|
||||
await _server?.close();
|
||||
log('OAuth callback server stopped gracefully');
|
||||
} catch (e) {
|
||||
log('Error during graceful server stop: $e');
|
||||
} finally {
|
||||
_server = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the waiting callback operation.
|
||||
/// This is useful when the user wants to cancel the OAuth flow manually.
|
||||
void cancel([String reason = 'OAuth flow cancelled by user']) {
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = null;
|
||||
_stopHeartbeatMonitoring();
|
||||
|
||||
if (!_completer.isCompleted) {
|
||||
_completer.completeError(Exception('OAuth callback cancelled: $reason'));
|
||||
}
|
||||
|
||||
// Automatically stop the server when cancelled
|
||||
_stopServerOnError(reason);
|
||||
}
|
||||
|
||||
/// Starts heartbeat monitoring to detect browser tab closure
|
||||
void _startHeartbeatMonitoring() {
|
||||
_lastHeartbeat = DateTime.now();
|
||||
_browserTabActive = true;
|
||||
|
||||
// Check for heartbeat every 5 seconds
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// If no heartbeat received for 5 seconds and we had an active tab before
|
||||
if (_browserTabActive &&
|
||||
_lastHeartbeat != null &&
|
||||
now.difference(_lastHeartbeat!).inSeconds > 5) {
|
||||
log(
|
||||
'Browser tab appears to be closed (no heartbeat for ${now.difference(_lastHeartbeat!).inSeconds}s)',
|
||||
);
|
||||
|
||||
if (!_completer.isCompleted) {
|
||||
_completer.completeError(
|
||||
Exception(
|
||||
'OAuth authorization cancelled: Browser tab was closed without completing the authorization process. '
|
||||
'Please try again and complete the authorization in your browser.',
|
||||
),
|
||||
);
|
||||
}
|
||||
timer.cancel();
|
||||
|
||||
// Automatically stop the server when browser tab is closed
|
||||
_stopServerOnError(
|
||||
'Browser tab closed without completing authorization',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Stops heartbeat monitoring
|
||||
void _stopHeartbeatMonitoring() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
_lastHeartbeat = null;
|
||||
_browserTabActive = false;
|
||||
}
|
||||
|
||||
/// Stops the server immediately due to an error condition
|
||||
/// This is used for automatic cleanup when errors occur
|
||||
void _stopServerOnError(String reason) {
|
||||
if (_server == null) {
|
||||
log('OAuth callback server already stopped, skipping error stop');
|
||||
return;
|
||||
}
|
||||
|
||||
log('Stopping OAuth callback server due to error: $reason');
|
||||
|
||||
// Cancel any active timers
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = null;
|
||||
_stopHeartbeatMonitoring();
|
||||
|
||||
// Close the server without waiting
|
||||
_server
|
||||
?.close(force: true)
|
||||
.then((_) {
|
||||
log('OAuth callback server forcefully stopped due to error');
|
||||
})
|
||||
.catchError((error) {
|
||||
log('Error while force-stopping server: $error');
|
||||
});
|
||||
|
||||
_server = null;
|
||||
}
|
||||
|
||||
void _handleRequest(HttpRequest request) {
|
||||
log('OAuth request received: ${request.uri}');
|
||||
|
||||
try {
|
||||
// Handle heartbeat requests
|
||||
if (request.uri.path == '/heartbeat') {
|
||||
_lastHeartbeat = DateTime.now();
|
||||
_browserTabActive = true;
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.add('Access-Control-Allow-Origin', '*')
|
||||
..headers.add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
..headers.add('Access-Control-Allow-Headers', 'Content-Type')
|
||||
..write('ok')
|
||||
..close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle OPTIONS preflight requests for CORS
|
||||
if (request.method == 'OPTIONS') {
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.add('Access-Control-Allow-Origin', '*')
|
||||
..headers.add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
..headers.add('Access-Control-Allow-Headers', 'Content-Type')
|
||||
..close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an authorization callback (has 'code' or 'error' parameters)
|
||||
final uri = request.uri;
|
||||
final hasCode = uri.queryParameters.containsKey('code');
|
||||
final hasError = uri.queryParameters.containsKey('error');
|
||||
|
||||
String responseHtml;
|
||||
|
||||
if (hasError) {
|
||||
final error = uri.queryParameters['error'] ?? 'unknown_error';
|
||||
final errorDescription =
|
||||
uri.queryParameters['error_description'] ??
|
||||
'No description provided';
|
||||
responseHtml = _generateErrorHtml(error, errorDescription);
|
||||
|
||||
// Complete the future with error and stop server
|
||||
if (!_completer.isCompleted) {
|
||||
_completer.completeError(
|
||||
Exception('OAuth authorization failed: $error - $errorDescription'),
|
||||
);
|
||||
}
|
||||
|
||||
// Send response to browser first, then stop server after a delay
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.html
|
||||
..write(responseHtml)
|
||||
..close();
|
||||
|
||||
// Stop server after sending response (with a small delay)
|
||||
Timer(const Duration(seconds: 1), () {
|
||||
_stopServerOnError('OAuth authorization error: $error');
|
||||
});
|
||||
|
||||
return;
|
||||
} else if (hasCode) {
|
||||
responseHtml = _generateSuccessHtml();
|
||||
} else {
|
||||
responseHtml = _generateInfoHtml();
|
||||
}
|
||||
|
||||
// Send response to the browser
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.html
|
||||
..write(responseHtml)
|
||||
..close();
|
||||
|
||||
// Complete the future with the full callback URL
|
||||
if (!_completer.isCompleted) {
|
||||
_completer.complete(request.uri.toString());
|
||||
}
|
||||
|
||||
// For successful authorization, schedule server stop after response
|
||||
if (hasCode) {
|
||||
Timer(const Duration(seconds: 6), () {
|
||||
_stopServerOnError('OAuth flow completed successfully');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log('Error handling OAuth callback request: $e');
|
||||
|
||||
// Complete with error if not already completed
|
||||
if (!_completer.isCompleted) {
|
||||
_completer.completeError(
|
||||
Exception('OAuth callback request handling failed: $e'),
|
||||
);
|
||||
}
|
||||
|
||||
// Try to send an error response
|
||||
try {
|
||||
request.response
|
||||
..statusCode = HttpStatus.internalServerError
|
||||
..write('Internal server error occurred during OAuth callback')
|
||||
..close();
|
||||
} catch (responseError) {
|
||||
log('Failed to send error response: $responseError');
|
||||
}
|
||||
|
||||
// Stop server due to error
|
||||
_stopServerOnError('Request handling error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _generateSuccessHtml() {
|
||||
return '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Authentication Successful</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f8f9fa; }
|
||||
.container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
.success { color: #28a745; font-size: 48px; margin-bottom: 20px; }
|
||||
.title { color: #333; font-size: 24px; margin-bottom: 15px; }
|
||||
.message { color: #6c757d; font-size: 16px; line-height: 1.5; }
|
||||
.countdown { color: #007bff; font-weight: bold; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success">✓</div>
|
||||
<div class="title">Authentication Successful!</div>
|
||||
<div class="message">
|
||||
Your OAuth authorization was completed successfully.
|
||||
You can now close this window and return to API Dash.
|
||||
</div>
|
||||
<div class="countdown" id="countdown">This window will close automatically in <span id="timer">5</span> seconds...</div>
|
||||
</div>
|
||||
<script>
|
||||
let seconds = 5;
|
||||
const timer = document.getElementById('timer');
|
||||
|
||||
// Send heartbeat to let server know we're still active
|
||||
const sendHeartbeat = () => {
|
||||
fetch('/heartbeat', { method: 'GET', mode: 'no-cors' }).catch(() => {
|
||||
// Ignore fetch errors as we're just sending a signal
|
||||
});
|
||||
};
|
||||
|
||||
// Send initial heartbeat and set up periodic heartbeats
|
||||
sendHeartbeat();
|
||||
const heartbeatInterval = setInterval(sendHeartbeat, 2000);
|
||||
|
||||
// Countdown timer
|
||||
const countdown = setInterval(() => {
|
||||
seconds--;
|
||||
timer.textContent = seconds;
|
||||
if (seconds <= 0) {
|
||||
clearInterval(countdown);
|
||||
clearInterval(heartbeatInterval);
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Send notification when window is about to close
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
// Try to send final heartbeat to indicate closure
|
||||
navigator.sendBeacon('/heartbeat', 'closing');
|
||||
});
|
||||
|
||||
// Handle when user manually closes the window
|
||||
window.addEventListener('unload', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
}
|
||||
|
||||
String _generateErrorHtml(String error, String errorDescription) {
|
||||
return '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Authentication Failed</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f8f9fa; }
|
||||
.container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
.error { color: #dc3545; font-size: 48px; margin-bottom: 20px; }
|
||||
.title { color: #333; font-size: 24px; margin-bottom: 15px; }
|
||||
.message { color: #6c757d; font-size: 16px; line-height: 1.5; margin-bottom: 20px; }
|
||||
.details { background: #f8f9fa; padding: 15px; border-radius: 4px; font-family: monospace; font-size: 14px; color: #495057; margin: 15px 0; }
|
||||
.countdown { color: #007bff; font-weight: bold; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="error">✗</div>
|
||||
<div class="title">Authentication Failed</div>
|
||||
<div class="message">
|
||||
The OAuth authorization was not completed successfully.
|
||||
Please try again from API Dash.
|
||||
</div>
|
||||
<div class="details">
|
||||
<strong>Error:</strong> $error<br>
|
||||
<strong>Description:</strong> $errorDescription
|
||||
</div>
|
||||
<div class="countdown" id="countdown">This window will close automatically in <span id="timer">10</span> seconds...</div>
|
||||
</div>
|
||||
<script>
|
||||
let seconds = 10;
|
||||
const timer = document.getElementById('timer');
|
||||
|
||||
// Send heartbeat to let server know we're still active
|
||||
const sendHeartbeat = () => {
|
||||
fetch('/heartbeat', { method: 'GET', mode: 'no-cors' }).catch(() => {
|
||||
// Ignore fetch errors
|
||||
});
|
||||
};
|
||||
|
||||
// Send initial heartbeat and set up periodic heartbeats
|
||||
sendHeartbeat();
|
||||
const heartbeatInterval = setInterval(sendHeartbeat, 2000);
|
||||
|
||||
// Countdown timer
|
||||
const countdown = setInterval(() => {
|
||||
seconds--;
|
||||
timer.textContent = seconds;
|
||||
if (seconds <= 0) {
|
||||
clearInterval(countdown);
|
||||
clearInterval(heartbeatInterval);
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Handle window closing events
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
navigator.sendBeacon('/heartbeat', 'closing');
|
||||
});
|
||||
|
||||
window.addEventListener('unload', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
}
|
||||
|
||||
String _generateInfoHtml() {
|
||||
return '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Callback Server</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f8f9fa; }
|
||||
.container { max-width: 500px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
.info { color: #17a2b8; font-size: 48px; margin-bottom: 20px; }
|
||||
.title { color: #333; font-size: 24px; margin-bottom: 15px; }
|
||||
.message { color: #6c757d; font-size: 16px; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="info">ℹ</div>
|
||||
<div class="title">OAuth Callback Server</div>
|
||||
<div class="message">
|
||||
This is the OAuth callback endpoint for API Dash.
|
||||
If you're seeing this page, the callback server is running correctly.
|
||||
Please return to API Dash to complete the OAuth flow.
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Send heartbeat to let server know we're still active even on info page
|
||||
const sendHeartbeat = () => {
|
||||
fetch('/heartbeat', { method: 'GET', mode: 'no-cors' }).catch(() => {
|
||||
// Ignore fetch errors
|
||||
});
|
||||
};
|
||||
|
||||
// Send initial heartbeat and set up periodic heartbeats
|
||||
sendHeartbeat();
|
||||
const heartbeatInterval = setInterval(sendHeartbeat, 2000);
|
||||
|
||||
// Handle window closing events
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
navigator.sendBeacon('/heartbeat', 'closing');
|
||||
});
|
||||
|
||||
window.addEventListener('unload', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'http_client_manager.dart';
|
||||
export 'http_service.dart';
|
||||
export 'oauth_callback_server.dart';
|
||||
|
||||
@@ -192,7 +192,8 @@ Future<HttpRequestModel> handleAuth(
|
||||
|
||||
switch (oauth2.grantType) {
|
||||
case OAuth2GrantType.authorizationCode:
|
||||
final res = await oAuth2AuthorizationCodeGrantHandler(
|
||||
// Use localhost callback server for desktop platforms, fallback to custom scheme for mobile
|
||||
final res = await oAuth2AuthorizationCodeGrant(
|
||||
identifier: oauth2.clientId,
|
||||
secret: oauth2.clientSecret,
|
||||
authorizationEndpoint: Uri.parse(oauth2.authorizationUrl),
|
||||
@@ -205,13 +206,27 @@ Future<HttpRequestModel> handleAuth(
|
||||
credentialsFile: credentialsFile,
|
||||
scope: oauth2.scope,
|
||||
);
|
||||
debugPrint(res.credentials.accessToken);
|
||||
|
||||
// Clean up the callback server if it exists and is still running
|
||||
// Note: The server might have already stopped itself due to timeout/error/completion
|
||||
final server = res.$2;
|
||||
if (server != null) {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Error stopping OAuth callback server (might already be stopped): $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(res.$1.credentials.accessToken);
|
||||
|
||||
// Add the access token to the request headers
|
||||
updatedHeaders.add(
|
||||
NameValueModel(
|
||||
name: 'Authorization',
|
||||
value: 'Bearer ${res.credentials.accessToken}',
|
||||
value: 'Bearer ${res.$1.credentials.accessToken}',
|
||||
),
|
||||
);
|
||||
updatedHeaderEnabledList.add(true);
|
||||
|
||||
@@ -4,10 +4,17 @@ import 'dart:io';
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:oauth2/oauth2.dart' as oauth2;
|
||||
|
||||
import '../../models/models.dart';
|
||||
import '../../models/auth/auth_oauth2_model.dart';
|
||||
import '../../services/http_client_manager.dart';
|
||||
import '../../services/oauth_callback_server.dart';
|
||||
import '../platform_utils.dart';
|
||||
|
||||
Future<oauth2.Client> oAuth2AuthorizationCodeGrantHandler({
|
||||
/// Advanced OAuth2 authorization code grant handler that returns both the client and server
|
||||
/// for cases where you need manual control over the callback server lifecycle.
|
||||
///
|
||||
/// Returns a tuple of (oauth2.Client, OAuthCallbackServer?) where the server is null for mobile platforms.
|
||||
/// The server should be stopped by the caller to clean up resources.
|
||||
Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({
|
||||
required String identifier,
|
||||
required String secret,
|
||||
required Uri authorizationEndpoint,
|
||||
@@ -17,19 +24,17 @@ Future<oauth2.Client> oAuth2AuthorizationCodeGrantHandler({
|
||||
String? state,
|
||||
String? scope,
|
||||
}) async {
|
||||
// Check for existing valid credentials first
|
||||
if (await credentialsFile.exists()) {
|
||||
try {
|
||||
final json = await credentialsFile.readAsString();
|
||||
|
||||
final credentials = oauth2.Credentials.fromJson(json);
|
||||
|
||||
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
|
||||
log('Using existing valid credentials');
|
||||
|
||||
return oauth2.Client(
|
||||
credentials,
|
||||
identifier: identifier,
|
||||
secret: secret,
|
||||
return (
|
||||
oauth2.Client(credentials, identifier: identifier, secret: secret),
|
||||
null,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -42,50 +47,120 @@ Future<oauth2.Client> oAuth2AuthorizationCodeGrantHandler({
|
||||
final httpClientManager = HttpClientManager();
|
||||
final baseClient = httpClientManager.createClientWithJsonAccept(requestId);
|
||||
|
||||
final grant = oauth2.AuthorizationCodeGrant(
|
||||
identifier,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
secret: secret,
|
||||
httpClient: baseClient,
|
||||
);
|
||||
|
||||
final authorizationUrl = grant.getAuthorizationUrl(
|
||||
redirectUrl,
|
||||
scopes: scope != null ? [scope] : null,
|
||||
state: state,
|
||||
);
|
||||
log('Generated authorization URL: ${authorizationUrl.toString()}');
|
||||
log('Expected redirect URL: ${redirectUrl.toString()}');
|
||||
|
||||
final uri = await FlutterWebAuth2.authenticate(
|
||||
url: authorizationUrl.toString(),
|
||||
callbackUrlScheme: redirectUrl.scheme,
|
||||
options: const FlutterWebAuth2Options(useWebview: true),
|
||||
);
|
||||
OAuthCallbackServer? callbackServer;
|
||||
Uri actualRedirectUrl = redirectUrl;
|
||||
|
||||
try {
|
||||
// Use standard oauth2 package for other providers
|
||||
// Use localhost callback server for desktop platforms
|
||||
if (PlatformUtils.shouldUseLocalhostCallback) {
|
||||
callbackServer = OAuthCallbackServer();
|
||||
final localhostUrl = await callbackServer.start();
|
||||
actualRedirectUrl = Uri.parse(localhostUrl);
|
||||
log('Using localhost callback server: $localhostUrl');
|
||||
} else {
|
||||
log('Using custom scheme callback: ${redirectUrl.toString()}');
|
||||
}
|
||||
|
||||
final grant = oauth2.AuthorizationCodeGrant(
|
||||
identifier,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
secret: secret,
|
||||
httpClient: baseClient,
|
||||
);
|
||||
|
||||
final authorizationUrl = grant.getAuthorizationUrl(
|
||||
actualRedirectUrl,
|
||||
scopes: scope != null ? [scope] : null,
|
||||
state: state,
|
||||
);
|
||||
|
||||
log('Generated authorization URL: ${authorizationUrl.toString()}');
|
||||
log('Expected redirect URL: ${actualRedirectUrl.toString()}');
|
||||
|
||||
String callbackUri;
|
||||
|
||||
if (PlatformUtils.shouldUseLocalhostCallback && callbackServer != null) {
|
||||
// For desktop: Open the authorization URL in the default browser
|
||||
// and wait for the callback on the localhost server with a 3-minute timeout
|
||||
await _openUrlInBrowser(authorizationUrl.toString());
|
||||
|
||||
try {
|
||||
callbackUri = await callbackServer.waitForCallback(
|
||||
timeout: const Duration(minutes: 3),
|
||||
);
|
||||
// Convert the relative callback to full URL
|
||||
callbackUri =
|
||||
'http://localhost${Uri.parse(callbackUri).path}${Uri.parse(callbackUri).query.isNotEmpty ? '?${Uri.parse(callbackUri).query}' : ''}';
|
||||
} on TimeoutException catch (e) {
|
||||
log('OAuth callback timeout: ${e.message}');
|
||||
throw Exception(
|
||||
'OAuth authorization timed out after 3 minutes. '
|
||||
'Please try again and complete the authorization in your browser. '
|
||||
'If you closed the browser tab, please restart the OAuth flow.',
|
||||
);
|
||||
} catch (e) {
|
||||
// Handle custom exceptions like browser tab closure
|
||||
final errorMessage = e.toString();
|
||||
if (errorMessage.contains('Browser tab was closed')) {
|
||||
log('OAuth authorization cancelled: Browser tab closed');
|
||||
throw Exception(
|
||||
'OAuth authorization was cancelled because the browser tab was closed. '
|
||||
'Please try again and complete the authorization process without closing the browser tab.',
|
||||
);
|
||||
} else if (errorMessage.contains('OAuth callback cancelled')) {
|
||||
log('OAuth authorization cancelled by user');
|
||||
throw Exception(
|
||||
'OAuth authorization was cancelled. Please try again if you want to complete the authentication.',
|
||||
);
|
||||
} else {
|
||||
log('OAuth callback error: $errorMessage');
|
||||
throw Exception('OAuth authorization failed: $errorMessage');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For mobile: Use the standard flutter_web_auth_2 approach
|
||||
callbackUri = await FlutterWebAuth2.authenticate(
|
||||
url: authorizationUrl.toString(),
|
||||
callbackUrlScheme: actualRedirectUrl.scheme,
|
||||
options: const FlutterWebAuth2Options(
|
||||
useWebview: true,
|
||||
windowName: 'OAuth Authorization - API Dash',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
log('Received callback URI: $callbackUri');
|
||||
|
||||
// Parse the callback URI and handle the authorization response
|
||||
final callbackUriParsed = Uri.parse(callbackUri);
|
||||
final client = await grant.handleAuthorizationResponse(
|
||||
Uri.parse(uri).queryParameters,
|
||||
callbackUriParsed.queryParameters,
|
||||
);
|
||||
|
||||
log('OAuth2 authorization successful, saving credentials');
|
||||
await credentialsFile.writeAsString(client.credentials.toJson());
|
||||
log(client.credentials.toJson());
|
||||
|
||||
// Clean up the HTTP client
|
||||
httpClientManager.closeClient(requestId);
|
||||
|
||||
return client;
|
||||
return (client, callbackServer);
|
||||
} catch (e) {
|
||||
log('Error handling authorization response: $e');
|
||||
log('URI query parameters: ${Uri.parse(uri).queryParameters}');
|
||||
|
||||
// Clean up the HTTP client on error
|
||||
httpClientManager.closeClient(requestId);
|
||||
|
||||
log('Error during OAuth2 flow: $e');
|
||||
// Clean up the callback server immediately on error
|
||||
if (callbackServer != null) {
|
||||
try {
|
||||
await callbackServer.stop();
|
||||
log('Callback server stopped due to OAuth2 flow error');
|
||||
} catch (serverError) {
|
||||
log(
|
||||
'Error stopping callback server during error cleanup: $serverError',
|
||||
);
|
||||
}
|
||||
}
|
||||
// Re-throw the original error
|
||||
rethrow;
|
||||
} finally {
|
||||
// Clean up HTTP client
|
||||
httpClientManager.closeClient(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,3 +297,32 @@ Future<oauth2.Client> oAuth2ResourceOwnerPasswordGrantHandler({
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a URL in the default system browser.
|
||||
/// This is used for desktop platforms where we want to open the OAuth authorization URL
|
||||
/// in the user's default browser and use localhost callback server to capture the response.
|
||||
Future<void> _openUrlInBrowser(String url) async {
|
||||
try {
|
||||
if (PlatformUtils.isDesktop) {
|
||||
Process? process;
|
||||
if (Platform.isMacOS) {
|
||||
process = await Process.start('open', [url]);
|
||||
} else if (Platform.isWindows) {
|
||||
process = await Process.start('rundll32', [
|
||||
'url.dll,FileProtocolHandler',
|
||||
url,
|
||||
]);
|
||||
} else if (Platform.isLinux) {
|
||||
process = await Process.start('xdg-open', [url]);
|
||||
}
|
||||
|
||||
if (process != null) {
|
||||
await process.exitCode; // Wait for the process to complete
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log('Error opening URL in browser: $e');
|
||||
// Fallback: throw an exception so the calling code can handle it
|
||||
throw Exception('Failed to open authorization URL in browser: $e');
|
||||
}
|
||||
}
|
||||
|
||||
19
packages/better_networking/lib/utils/platform_utils.dart
Normal file
19
packages/better_networking/lib/utils/platform_utils.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Platform detection utilities for the better_networking package.
|
||||
class PlatformUtils {
|
||||
/// Returns true if running on desktop platforms (macOS, Windows, Linux).
|
||||
static bool get isDesktop =>
|
||||
!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
|
||||
|
||||
/// Returns true if running on mobile platforms (iOS, Android).
|
||||
static bool get isMobile => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
||||
|
||||
/// Returns true if running on web.
|
||||
static bool get isWeb => kIsWeb;
|
||||
|
||||
/// Returns true if OAuth should use localhost callback server.
|
||||
/// This is true for desktop platforms.
|
||||
static bool get shouldUseLocalhostCallback => isDesktop;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export 'content_type_utils.dart';
|
||||
export 'graphql_utils.dart';
|
||||
export 'http_request_utils.dart';
|
||||
export 'http_response_utils.dart';
|
||||
export 'platform_utils.dart';
|
||||
export 'string_utils.dart' hide RandomStringGenerator;
|
||||
export 'uri_utils.dart';
|
||||
export 'auth/handle_auth.dart';
|
||||
|
||||
@@ -30,7 +30,7 @@ dependencies:
|
||||
xml: ^6.3.0
|
||||
oauth1: ^2.1.0
|
||||
oauth2: ^2.0.3
|
||||
flutter_web_auth_2: ^4.1.0
|
||||
flutter_web_auth_2: ^5.0.0-alpha.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:io';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:better_networking/services/oauth_callback_server.dart';
|
||||
|
||||
void main() {
|
||||
group('OAuthCallbackServer', () {
|
||||
late OAuthCallbackServer server;
|
||||
|
||||
setUp(() {
|
||||
server = OAuthCallbackServer();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('should start server and return callback URL', () async {
|
||||
final callbackUrl = await server.start();
|
||||
|
||||
expect(callbackUrl, startsWith('http://localhost:'));
|
||||
expect(callbackUrl, endsWith('/callback'));
|
||||
|
||||
// Verify the server is actually running by making a simple HTTP request
|
||||
final client = HttpClient();
|
||||
final uri = Uri.parse(callbackUrl);
|
||||
final request = await client.getUrl(uri);
|
||||
final response = await request.close();
|
||||
|
||||
expect(response.statusCode, equals(HttpStatus.ok));
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('should start server with custom path', () async {
|
||||
final callbackUrl = await server.start(path: '/custom/oauth');
|
||||
|
||||
expect(callbackUrl, startsWith('http://localhost:'));
|
||||
expect(callbackUrl, endsWith('/custom/oauth'));
|
||||
});
|
||||
|
||||
test('should handle callback and return full URL', () async {
|
||||
final callbackUrl = await server.start();
|
||||
|
||||
// Start waiting for callback in a separate isolate
|
||||
final callbackFuture = server.waitForCallback();
|
||||
|
||||
// Simulate an OAuth callback with query parameters
|
||||
final client = HttpClient();
|
||||
final uri = Uri.parse('$callbackUrl?code=test_code&state=test_state');
|
||||
final request = await client.getUrl(uri);
|
||||
final response = await request.close();
|
||||
|
||||
expect(response.statusCode, equals(HttpStatus.ok));
|
||||
|
||||
// Verify we get the callback URL with parameters
|
||||
final receivedCallback = await callbackFuture;
|
||||
expect(receivedCallback, contains('code=test_code'));
|
||||
expect(receivedCallback, contains('state=test_state'));
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('should find available port when default is busy', () async {
|
||||
// Start a server on port 8080 to make it busy
|
||||
final busyServer = await HttpServer.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
8080,
|
||||
);
|
||||
|
||||
try {
|
||||
final callbackUrl = await server.start();
|
||||
|
||||
// Should find a different port
|
||||
expect(callbackUrl, startsWith('http://localhost:'));
|
||||
expect(callbackUrl, isNot(contains(':8080')));
|
||||
} finally {
|
||||
await busyServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('should throw exception when no ports available', () async {
|
||||
// This test would be difficult to implement without actually occupying all ports,
|
||||
// so we'll skip it for now. In a real scenario, you could mock the HttpServer.bind method.
|
||||
});
|
||||
|
||||
test('should stop server cleanly', () async {
|
||||
final callbackUrl = await server.start();
|
||||
await server.stop();
|
||||
|
||||
// Verify server is stopped by trying to connect
|
||||
final client = HttpClient();
|
||||
final uri = Uri.parse(callbackUrl);
|
||||
|
||||
expect(() async {
|
||||
final request = await client.getUrl(uri);
|
||||
await request.close();
|
||||
}, throwsA(isA<SocketException>()));
|
||||
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:test/test.dart';
|
||||
import 'package:better_networking/utils/platform_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('PlatformUtils', () {
|
||||
test('should detect platform types correctly', () {
|
||||
// Note: These tests will behave differently based on the platform running the tests
|
||||
// In a real CI environment, you might want to mock these or run platform-specific tests
|
||||
|
||||
expect(PlatformUtils.isDesktop, isA<bool>());
|
||||
expect(PlatformUtils.isMobile, isA<bool>());
|
||||
expect(PlatformUtils.isWeb, isA<bool>());
|
||||
expect(PlatformUtils.shouldUseLocalhostCallback, isA<bool>());
|
||||
});
|
||||
|
||||
test('should have mutually exclusive platform types', () {
|
||||
// At most one of these should be true (could be none if running on an unknown platform)
|
||||
final platformCount = [
|
||||
PlatformUtils.isDesktop,
|
||||
PlatformUtils.isMobile,
|
||||
PlatformUtils.isWeb,
|
||||
].where((x) => x).length;
|
||||
|
||||
expect(platformCount, lessThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
test('shouldUseLocalhostCallback should match isDesktop', () {
|
||||
expect(
|
||||
PlatformUtils.shouldUseLocalhostCallback,
|
||||
equals(PlatformUtils.isDesktop),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -672,18 +672,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_web_auth_2
|
||||
sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696"
|
||||
sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "5.0.0-alpha.3"
|
||||
flutter_web_auth_2_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_web_auth_2_platform_interface
|
||||
sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d
|
||||
sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "5.0.0-alpha.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
||||
Reference in New Issue
Block a user