Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion lib/src/auth/solid_auth_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ 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/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';

Expand Down Expand Up @@ -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!,
]);
Comment on lines +196 to +199
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();

Expand Down Expand Up @@ -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<void> 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;
Expand Down
36 changes: 36 additions & 0 deletions lib/src/utils/loopback_listener_guard.dart
Original file line number Diff line number Diff line change
@@ -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';
130 changes: 130 additions & 0 deletions lib/src/utils/loopback_listener_guard_io.dart
Original file line number Diff line number Diff line change
@@ -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<bool> releaseStaleLoopbackListeners(List<Uri> 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 = <String>{};
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');
}
Comment on lines +83 to +87
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<void>.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:<port>/...` 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<bool> _isPortFree(int port) async {
try {
final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, port);
await probe.close();
return true;
} on SocketException {
return false;
}
}
36 changes: 36 additions & 0 deletions lib/src/utils/loopback_listener_guard_stub.dart
Original file line number Diff line number Diff line change
@@ -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<bool> releaseStaleLoopbackListeners(List<Uri> redirectUris) async =>
true;
Loading
Loading