From afe5c585b91abf391dc1f68ca98f881908ff5415 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Sat, 4 Jul 2026 01:00:39 +1000 Subject: [PATCH 1/2] Fix port-in-use crash when a logout/login browser flow is abandoned --- lib/src/auth/solid_auth_manager.dart | 39 ++++- lib/src/utils/loopback_listener_guard.dart | 36 +++++ lib/src/utils/loopback_listener_guard_io.dart | 130 ++++++++++++++++ .../utils/loopback_listener_guard_stub.dart | 36 +++++ test/loopback_listener_guard_test.dart | 146 ++++++++++++++++++ 5 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 lib/src/utils/loopback_listener_guard.dart create mode 100644 lib/src/utils/loopback_listener_guard_io.dart create mode 100644 lib/src/utils/loopback_listener_guard_stub.dart create mode 100644 test/loopback_listener_guard_test.dart diff --git a/lib/src/auth/solid_auth_manager.dart b/lib/src/auth/solid_auth_manager.dart index 7184ed2..6a22909 100644 --- a/lib/src/auth/solid_auth_manager.dart +++ b/lib/src/auth/solid_auth_manager.dart @@ -35,6 +35,7 @@ import 'package:solid_auth/src/auth/solid_auth_session_store.dart'; import 'package:solid_auth/src/auth/solid_oidc_config.dart'; import 'package:solid_auth/src/auth/solid_oidc_manager_factory.dart'; import 'package:solid_auth/src/dpop/dpop_key_manager.dart'; +import 'package:solid_auth/src/utils/loopback_listener_guard.dart'; import 'package:solid_auth/src/models/solid_auth_data.dart'; import 'package:solid_auth/src/models/solid_provider_metadata.dart'; import 'package:solid_auth/src/utils/solid_scopes.dart'; @@ -187,6 +188,22 @@ class SolidAuthManager { // await _oidcManager!.init(); // } + // Release any loopback listener left bound by an abandoned login or + // logout flow; otherwise the fresh flow would crash with a + // SocketException when it tries to bind the same redirect port + // (Windows / Linux desktop). + + final redirectPortsFree = await releaseStaleLoopbackListeners([ + config.redirectUri, + if (config.postLogoutRedirectUri != null) config.postLogoutRedirectUri!, + ]); + if (!redirectPortsFree) { + throw const SolidAuthException( + 'The login redirect port is in use by another application. ' + 'Close the application holding the port and try again.', + ); + } + _log.fine('Launching Authorization Code + PKCE flow'); final user = await _oidcManager!.loginAuthorizationCodeFlow(); @@ -352,9 +369,29 @@ class SolidAuthManager { } /// Logs out the user from the identity provider and clears local tokens. + /// + /// Before launching the browser end-session flow, any loopback redirect + /// listener left behind by a previously abandoned flow is released, so a + /// repeated logout cannot crash with a port-in-use SocketException + /// (previously seen on Windows and Linux when the user ignored the + /// browser's sign-out page and logged out again). If the redirect port + /// cannot be freed at all, the identity-provider round-trip is skipped + /// and only local state is cleared. Future logout() async { _log.info('Logging out'); - await _oidcManager?.logout(); + final redirectPortsFree = await releaseStaleLoopbackListeners([ + if (config.postLogoutRedirectUri != null) config.postLogoutRedirectUri!, + config.redirectUri, + ]); + if (redirectPortsFree) { + await _oidcManager?.logout(); + } else { + _log.warning( + 'Post-logout redirect port unavailable — clearing the local ' + 'session without the browser round-trip.', + ); + await _oidcManager?.forgetUser(); + } await _sessionStore.clearSession(); DpopKeyManager.clear(); // rotate key on logout for forward secrecy _keyManager = null; diff --git a/lib/src/utils/loopback_listener_guard.dart b/lib/src/utils/loopback_listener_guard.dart new file mode 100644 index 0000000..0912e9f --- /dev/null +++ b/lib/src/utils/loopback_listener_guard.dart @@ -0,0 +1,36 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen +library; + +// On platforms with `dart:io` (Windows, Linux, macOS, Android, iOS) the +// guard probes and, where necessary, releases the loopback redirect port +// used by `package:oidc` browser flows. On the web the loopback listener +// is never used, so a no-op stub is substituted at compile time. + +export 'loopback_listener_guard_stub.dart' + if (dart.library.io) 'loopback_listener_guard_io.dart'; diff --git a/lib/src/utils/loopback_listener_guard_io.dart b/lib/src/utils/loopback_listener_guard_io.dart new file mode 100644 index 0000000..04a61a3 --- /dev/null +++ b/lib/src/utils/loopback_listener_guard_io.dart @@ -0,0 +1,130 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen +library; + +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +final _log = Logger('solid_auth.LoopbackListenerGuard'); + +/// How long to wait for a stale listener to answer the release request. + +const _releaseRequestTimeout = Duration(seconds: 2); + +/// Releases loopback redirect listeners left behind by abandoned browser +/// flows. +/// +/// On Windows and Linux, `package:oidc` completes login and logout by +/// binding an HTTP listener on the loopback redirect port (for example +/// `http://localhost:4400/redirect`) and waiting for the browser to redirect +/// back to it. If the user abandons the browser window — say, by ignoring +/// the identity provider's sign-out confirmation page — that listener stays +/// bound indefinitely, and the next login or logout attempt dies with an +/// unhandled [SocketException] the moment it tries to bind the same port. +/// +/// This guard probes each fixed loopback port named in [redirectUris]. When +/// a port is already bound, it sends a plain GET to the redirect URI, which +/// the stale listener treats as the browser redirect it has been waiting +/// for: it responds, closes its server and lets the abandoned flow complete +/// harmlessly (a redirect carrying no `state` merely clears the local user). +/// +/// Returns true when every relevant port is free (possibly after a release), +/// or false when a port remains bound — for instance when an unrelated +/// application owns it — in which case the caller should avoid starting a +/// loopback-based browser flow. + +Future releaseStaleLoopbackListeners(List redirectUris) async { + final candidates = redirectUris.where(_isFixedLoopbackHttpUri).toList(); + if (candidates.isEmpty) return true; + + var allFree = true; + for (final port in candidates.map((uri) => uri.port).toSet()) { + if (await _isPortFree(port)) continue; + + _log.info( + 'Loopback port $port is already bound — attempting to release a ' + 'stale redirect listener from an abandoned browser flow.', + ); + + var freed = false; + final triedPaths = {}; + for (final uri in candidates.where((u) => u.port == port)) { + // The stale listener only answers its own path, so try each + // distinct path once. + + if (!triedPaths.add(uri.path)) continue; + try { + await http.get(uri).timeout(_releaseRequestTimeout); + } on Object catch (e) { + _log.finer('Release request to $uri failed: $e'); + } + if (await _isPortFree(port)) { + freed = true; + break; + } + } + + if (freed) { + // Give the released flow a moment to finish unwinding (it clears + // the local user on completion) before a new flow reuses the port. + + await Future.delayed(const Duration(milliseconds: 100)); + } else { + _log.warning( + 'Loopback port $port could not be released; it may be held by ' + 'another application.', + ); + allFree = false; + } + } + return allFree; +} + +/// True for `http://localhost:/...` style URIs with an explicit, +/// fixed port — the only kind that can collide across flows. A port of 0 +/// asks the operating system for an ephemeral port, so it never conflicts. + +bool _isFixedLoopbackHttpUri(Uri uri) { + if (uri.scheme != 'http') return false; + if (!uri.hasPort || uri.port == 0) return false; + return uri.host == 'localhost' || uri.host == '127.0.0.1'; +} + +/// Probes whether [port] can be bound on the IPv4 loopback interface. + +Future _isPortFree(int port) async { + try { + final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, port); + await probe.close(); + return true; + } on SocketException { + return false; + } +} diff --git a/lib/src/utils/loopback_listener_guard_stub.dart b/lib/src/utils/loopback_listener_guard_stub.dart new file mode 100644 index 0000000..1159155 --- /dev/null +++ b/lib/src/utils/loopback_listener_guard_stub.dart @@ -0,0 +1,36 @@ +/// Support for flutter apps authenticating to a Solid server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen +library; + +/// Web implementation of the loopback listener guard. +/// +/// Browser-based flows on the web never bind a local loopback port, so +/// there is nothing to probe or release: always report the ports as free. + +Future releaseStaleLoopbackListeners(List redirectUris) async => + true; diff --git a/test/loopback_listener_guard_test.dart b/test/loopback_listener_guard_test.dart new file mode 100644 index 0000000..7e197ee --- /dev/null +++ b/test/loopback_listener_guard_test.dart @@ -0,0 +1,146 @@ +/// Tests for the loopback listener guard. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:solid_auth/src/utils/loopback_listener_guard.dart'; + +/// Binds a listener on [port] that mimics `oidc_loopback_listener`: it +/// serves a single GET on [path], then closes its server. The returned +/// completer completes when the listener has been released. + +Future> bindStaleOidcListener(int port, String path) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + final released = Completer(); + unawaited(() async { + await for (final request in server) { + if (request.method != 'GET' || request.uri.path != path) { + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + continue; + } + request.response.write('Please return to the app.'); + await request.response.close(); + await server.close(); + released.complete(); + return; + } + }()); + return released; +} + +Future portIsFree(int port) async { + try { + final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, port); + await probe.close(); + return true; + } on SocketException { + return false; + } +} + +void main() { + group('releaseStaleLoopbackListeners', () { + test('returns true when no candidate URI uses a fixed loopback port', + () async { + final free = await releaseStaleLoopbackListeners([ + Uri.parse('com.example.app://redirect'), + Uri.parse('https://example.com/redirect.html'), + Uri.parse('http://localhost:0/redirect'), + ]); + expect(free, isTrue); + }); + + test('returns true when the port is already free', () async { + final free = await releaseStaleLoopbackListeners([ + Uri.parse('http://localhost:49181/redirect'), + ]); + expect(free, isTrue); + expect(await portIsFree(49181), isTrue); + }); + + test('releases a stale single-response listener and frees the port', + () async { + const port = 49182; + final released = await bindStaleOidcListener(port, '/redirect'); + expect(await portIsFree(port), isFalse); + + final free = await releaseStaleLoopbackListeners([ + Uri.parse('http://localhost:$port/redirect'), + ]); + + expect(free, isTrue); + await released.future.timeout(const Duration(seconds: 2)); + expect(await portIsFree(port), isTrue); + }); + + test('tries each distinct path until the listener is released', () async { + const port = 49183; + final released = await bindStaleOidcListener(port, '/redirect'); + + // The first URI has the wrong path (the listener answers 404 and + // keeps waiting); the second matches and releases it. + + final free = await releaseStaleLoopbackListeners([ + Uri.parse('http://localhost:$port/logout'), + Uri.parse('http://localhost:$port/redirect'), + ]); + + expect(free, isTrue); + await released.future.timeout(const Duration(seconds: 2)); + expect(await portIsFree(port), isTrue); + }); + + test('returns false when the port is held by an unrelated server', + () async { + const port = 49184; + + // An unrelated server answers requests but never closes. + + final server = + await HttpServer.bind(InternetAddress.loopbackIPv4, port); + unawaited(() async { + await for (final request in server) { + request.response.write('not an oidc listener'); + await request.response.close(); + } + }()); + + final free = await releaseStaleLoopbackListeners([ + Uri.parse('http://localhost:$port/redirect'), + ]); + + expect(free, isFalse); + await server.close(force: true); + }); + }); +} From 5796295ef889e1fe64bfe869994619dbbad5adfb Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Sat, 4 Jul 2026 01:01:40 +1000 Subject: [PATCH 2/2] Lint --- lib/src/auth/solid_auth_manager.dart | 2 +- test/loopback_listener_guard_test.dart | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/src/auth/solid_auth_manager.dart b/lib/src/auth/solid_auth_manager.dart index 6a22909..8c2b8d1 100644 --- a/lib/src/auth/solid_auth_manager.dart +++ b/lib/src/auth/solid_auth_manager.dart @@ -35,9 +35,9 @@ import 'package:solid_auth/src/auth/solid_auth_session_store.dart'; import 'package:solid_auth/src/auth/solid_oidc_config.dart'; import 'package:solid_auth/src/auth/solid_oidc_manager_factory.dart'; import 'package:solid_auth/src/dpop/dpop_key_manager.dart'; -import 'package:solid_auth/src/utils/loopback_listener_guard.dart'; import 'package:solid_auth/src/models/solid_auth_data.dart'; import 'package:solid_auth/src/models/solid_provider_metadata.dart'; +import 'package:solid_auth/src/utils/loopback_listener_guard.dart'; import 'package:solid_auth/src/utils/solid_scopes.dart'; import 'package:solid_auth/src/utils/webid_utils.dart'; diff --git a/test/loopback_listener_guard_test.dart b/test/loopback_listener_guard_test.dart index 7e197ee..f2f5bbe 100644 --- a/test/loopback_listener_guard_test.dart +++ b/test/loopback_listener_guard_test.dart @@ -126,8 +126,7 @@ void main() { // An unrelated server answers requests but never closes. - final server = - await HttpServer.bind(InternetAddress.loopbackIPv4, port); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); unawaited(() async { await for (final request in server) { request.response.write('not an oidc listener');