From ead174cf5012d969dfe2f975cbc21ea7b9169a84 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 15 May 2026 00:10:22 +1000 Subject: [PATCH 1/3] Add cancelAuthenticate() and AuthCancelledException so callers can abort a stuck desktop OAuth flow when the user closes the browser window before completing login. Authenticator.cancel() is now idempotent and only tears down the shared callback server when no other flow needs it --- lib/solid_auth_client.dart | 69 +++++++++++++++++++++++++++- lib/src/openid/openid_client_io.dart | 35 +++++++++++--- 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/lib/solid_auth_client.dart b/lib/solid_auth_client.dart index f04bbb0..f332d80 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 catch (e) { + if (e.toString().contains('Flow was cancelled')) { + 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 = {}; From de3df056e43a3e23e02f8030b403c756722fecf7 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 15 May 2026 11:10:27 +1000 Subject: [PATCH 2/3] Add cancelAuthenticate() and AuthCancelledException so callers can abort a stuck desktop OAuth flow when the user closes the browser window before completing login. Authenticator.cancel() is now idempotent and only tears down the shared callback server when no other flow needs it --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 46064595e5c54a7cf993a72e8dd75ffb79971912 Mon Sep 17 00:00:00 2001 From: Tony Chen <128760989+tonypioneer@users.noreply.github.com> Date: Fri, 15 May 2026 11:32:19 +1000 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/solid_auth_client.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/solid_auth_client.dart b/lib/solid_auth_client.dart index f332d80..ed7baa9 100644 --- a/lib/solid_auth_client.dart +++ b/lib/solid_auth_client.dart @@ -317,8 +317,8 @@ Future authenticate( try { /// starts the authentication + authorisation process authResponse = await authenticator.authorize(); - } on Exception catch (e) { - if (e.toString().contains('Flow was cancelled')) { + } on Exception { + if (!identical(_pendingAuthenticator, authenticator)) { throw const AuthCancelledException(); } rethrow;