feat: implement local http server in desktop platforms for oauth2

This commit is contained in:
Udhay-Adithya
2025-08-03 12:59:34 +05:30
parent 98d7375071
commit 84ddbfa1ac
11 changed files with 826 additions and 53 deletions

View File

@@ -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

View File

@@ -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>
''';
}
}

View File

@@ -1,2 +1,3 @@
export 'http_client_manager.dart';
export 'http_service.dart';
export 'oauth_callback_server.dart';

View File

@@ -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);

View File

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

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

View File

@@ -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';

View File

@@ -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:

View File

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

View File

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

View File

@@ -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