Files
apidash/packages/better_networking/lib/utils/auth/oauth2_utils.dart
2025-08-07 16:31:27 +05:30

296 lines
9.9 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:oauth2/oauth2.dart' as oauth2;
import '../../models/auth/auth_oauth2_model.dart';
import '../../services/http_client_manager.dart';
import '../../services/oauth_callback_server.dart';
import '../platform_utils.dart';
/// 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,
required Uri tokenEndpoint,
required Uri redirectUrl,
required File? credentialsFile,
String? state,
String? scope,
}) async {
// Check for existing credentials first
if (credentialsFile != null && await credentialsFile.exists()) {
try {
final json = await credentialsFile.readAsString();
final credentials = oauth2.Credentials.fromJson(json);
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
return (
oauth2.Client(credentials, identifier: identifier, secret: secret),
null,
);
}
} catch (e) {
// Ignore credential reading errors and continue with fresh authentication
}
}
// Create a unique request ID for this OAuth flow
final requestId = 'oauth2-${DateTime.now().millisecondsSinceEpoch}';
final httpClientManager = HttpClientManager();
final baseClient = httpClientManager.createClientWithJsonAccept(requestId);
OAuthCallbackServer? callbackServer;
Uri actualRedirectUrl = redirectUrl;
try {
// Use localhost callback server for desktop platforms
if (PlatformUtils.shouldUseLocalhostCallback) {
callbackServer = OAuthCallbackServer();
final localhostUrl = await callbackServer.start();
actualRedirectUrl = Uri.parse(localhostUrl);
}
final grant = oauth2.AuthorizationCodeGrant(
identifier,
authorizationEndpoint,
tokenEndpoint,
secret: secret,
httpClient: baseClient,
);
final authorizationUrl = grant.getAuthorizationUrl(
actualRedirectUrl,
scopes: scope != null ? [scope] : null,
state: state,
);
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 {
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')) {
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')) {
throw Exception(
'OAuth authorization was cancelled. Please try again if you want to complete the authentication.',
);
} else {
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',
),
);
}
// Parse the callback URI and handle the authorization response
final callbackUriParsed = Uri.parse(callbackUri);
final client = await grant.handleAuthorizationResponse(
callbackUriParsed.queryParameters,
);
if (credentialsFile != null) {
await credentialsFile.writeAsString(client.credentials.toJson());
}
return (client, callbackServer);
} catch (e) {
// Clean up the callback server immediately on error
if (callbackServer != null) {
try {
await callbackServer.stop();
} catch (serverError) {
// Ignore server cleanup errors
}
}
// Re-throw the original error
rethrow;
} finally {
// Clean up HTTP client
httpClientManager.closeClient(requestId);
}
}
Future<oauth2.Client> oAuth2ClientCredentialsGrantHandler({
required AuthOAuth2Model oauth2Model,
required File? credentialsFile,
}) async {
// Try to use saved credentials
if (credentialsFile != null && await credentialsFile.exists()) {
try {
final json = await credentialsFile.readAsString();
final credentials = oauth2.Credentials.fromJson(json);
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
return oauth2.Client(
credentials,
identifier: oauth2Model.clientId,
secret: oauth2Model.clientSecret,
);
}
} catch (e) {
// Ignore credential reading errors and continue with fresh authentication
}
}
// Create a unique request ID for this OAuth flow
final requestId = 'oauth2-client-${DateTime.now().millisecondsSinceEpoch}';
final httpClientManager = HttpClientManager();
final baseClient = httpClientManager.createClientWithJsonAccept(requestId);
try {
// Otherwise, perform the client credentials grant
final client = await oauth2.clientCredentialsGrant(
Uri.parse(oauth2Model.accessTokenUrl),
oauth2Model.clientId,
oauth2Model.clientSecret,
scopes: oauth2Model.scope != null ? [oauth2Model.scope!] : null,
basicAuth: false,
httpClient: baseClient,
);
try {
if (credentialsFile != null) {
await credentialsFile.writeAsString(client.credentials.toJson());
}
} catch (e) {
// Ignore credential saving errors
}
// Clean up the HTTP client
httpClientManager.closeClient(requestId);
return client;
} catch (e) {
// Clean up the HTTP client on error
httpClientManager.closeClient(requestId);
rethrow;
}
}
Future<oauth2.Client> oAuth2ResourceOwnerPasswordGrantHandler({
required AuthOAuth2Model oauth2Model,
required File? credentialsFile,
}) async {
// Try to use saved credentials
if (credentialsFile != null && await credentialsFile.exists()) {
try {
final json = await credentialsFile.readAsString();
final credentials = oauth2.Credentials.fromJson(json);
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
return oauth2.Client(
credentials,
identifier: oauth2Model.clientId,
secret: oauth2Model.clientSecret,
);
}
} catch (e) {
// Ignore credential reading errors and continue with fresh authentication
}
}
if ((oauth2Model.username == null || oauth2Model.username!.isEmpty) ||
(oauth2Model.password == null || oauth2Model.password!.isEmpty)) {
throw Exception("Username or Password cannot be empty");
}
// Create a unique request ID for this OAuth flow
final requestId = 'oauth2-password-${DateTime.now().millisecondsSinceEpoch}';
final httpClientManager = HttpClientManager();
final baseClient = httpClientManager.createClientWithJsonAccept(requestId);
try {
// Otherwise, perform the owner password grant
final client = await oauth2.resourceOwnerPasswordGrant(
Uri.parse(oauth2Model.accessTokenUrl),
oauth2Model.username!,
oauth2Model.password!,
identifier: oauth2Model.clientId,
secret: oauth2Model.clientSecret,
scopes: oauth2Model.scope != null ? [oauth2Model.scope!] : null,
basicAuth: false,
httpClient: baseClient,
);
try {
if (credentialsFile != null) {
await credentialsFile.writeAsString(client.credentials.toJson());
}
} catch (e) {
// Ignore credential saving errors
}
// Clean up the HTTP client
httpClientManager.closeClient(requestId);
return client;
} catch (e) {
// Clean up the HTTP client on error
httpClientManager.closeClient(requestId);
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) {
// Fallback: throw an exception so the calling code can handle it
throw Exception('Failed to open authorization URL in browser: $e');
}
}