diff --git a/lib/solid_auth_client.dart b/lib/solid_auth_client.dart index f04bbb0..ed7baa9 100644 --- a/lib/solid_auth_client.dart +++ b/lib/solid_auth_client.dart @@ -56,6 +56,47 @@ PlatformInfo currPlatform = PlatformInfo(); AuthManager authManager = AuthManager(); +/// Thrown by [authenticate] when the in-flight OAuth flow is aborted via +/// [cancelAuthenticate]. Lets callers distinguish a deliberate user (or +/// programmatic) cancellation from a genuine network/server failure so they +/// can suppress error UI accordingly. + +class AuthCancelledException implements Exception { + final String message; + + const AuthCancelledException([ + this.message = 'Authentication was cancelled', + ]); + + @override + String toString() => 'AuthCancelledException: $message'; +} + +/// Reference to the desktop [oidc_mobile.Authenticator] currently waiting on +/// the OAuth callback. Tracked here so [cancelAuthenticate] can tear it down +/// from outside the awaiting code path without having to thread the handle +/// through the call site. + +oidc_mobile.Authenticator? _pendingAuthenticator; + +/// Returns true while a desktop [authenticate] call is awaiting the OAuth +/// browser redirect. Useful for UI code that wants to gate retry buttons +/// while a previous attempt is still pending. + +bool isAuthenticatePending() => _pendingAuthenticator != null; + +/// Aborts any in-flight desktop [authenticate] call. The awaited future +/// throws an [AuthCancelledException], the local OAuth callback HTTP server +/// is closed when no other flow needs it, and the cached authenticator +/// reference is cleared. + +Future cancelAuthenticate() async { + final pending = _pendingAuthenticator; + if (pending == null) return; + _pendingAuthenticator = null; + await pending.cancel(); +} + /// Dynamically register the user in the POD server Future clientDynamicReg( String regEndpoint, @@ -260,8 +301,32 @@ Future authenticate( 'Authentication process completed. You can now close this window!', ); - /// starts the authentication + authorisation process - authResponse = await authenticator.authorize(); + // Publish the in-flight authenticator so [cancelAuthenticate] can tear + // it down from outside this call. Any previously pending authenticator + // is cancelled first to free the shared local OAuth callback server. + final previous = _pendingAuthenticator; + if (previous != null) { + try { + await previous.cancel(); + } on Object { + // Best-effort cleanup; ignore failures from the prior flow. + } + } + _pendingAuthenticator = authenticator; + + try { + /// starts the authentication + authorisation process + authResponse = await authenticator.authorize(); + } on Exception { + if (!identical(_pendingAuthenticator, authenticator)) { + throw const AuthCancelledException(); + } + rethrow; + } finally { + if (identical(_pendingAuthenticator, authenticator)) { + _pendingAuthenticator = null; + } + } /// close the webview when finished /// closing web view function does not work in Windows applications diff --git a/lib/src/openid/openid_client_io.dart b/lib/src/openid/openid_client_io.dart index a5b87a6..1b5fb55 100644 --- a/lib/src/openid/openid_client_io.dart +++ b/lib/src/openid/openid_client_io.dart @@ -134,16 +134,37 @@ class Authenticator { /// Cancels the authentication flow. /// - /// This method will stop the local http server and complete the [authorize] - /// method with an error. + /// Errors the [authorize] future for this authenticator's state with a + /// `Flow was cancelled` [Exception] and tears down the shared local http + /// server when there are no other in-flight authentications waiting on it. /// - /// This method should be called when the user cancels the authentication flow - /// in the browser. + /// Idempotent: safe to call multiple times, and a no-op when the flow has + /// already completed or has no registered state. This matters on desktop + /// where there is no reliable signal that the user has closed the external + /// browser window, so cancellation may be requested speculatively. Future cancel() async { final state = flow.authenticationUri.queryParameters['state']; - _requestsByState[state!]?.completeError(Exception('Flow was cancelled')); - final server = await _requestServers.remove(port); - await server?.close(); + if (state == null) return; + + final completer = _requestsByState.remove(state); + if (completer != null && !completer.isCompleted) { + completer.completeError(Exception('Flow was cancelled')); + } + + // Only stop the local OAuth callback server when no other states are + // still waiting for a redirect. Closing it whilst another authenticator + // is still in flight would strand that flow on a dead socket. + if (_requestsByState.isEmpty) { + final serverFuture = _requestServers.remove(port); + if (serverFuture != null) { + try { + final server = await serverFuture; + await server.close(force: true); + } on Object { + // Server may already be closed or never finished binding — ignore. + } + } + } } static final Map> _requestServers = {}; diff --git a/pubspec.yaml b/pubspec.yaml index aae52e2..116e8a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,7 +26,7 @@ dependencies: dev_dependencies: flutter_lints: ^5.0.0 jwt_decoder: ^2.0.1 - # Keep dependency checker quiet. + # Keep the dependency checker quiet. solid_auth_example: path: example