feat(oidc): halkyon_oidc_flutter, the device's half of signing in with OpenID Connect (ADR 0023)

OidcBrowser opens the page of an attempt the server began (OidcStart),
waits until the browser comes back by an OidcReturn (the app's scheme,
a verified https address, or the loopback) and returns the provider's
code (OidcCode), its refusal (OidcDenied), or nothing when the person
closed the browser. It checks the returned state against the attempt's,
as the server never compares it; a wrong state, neither one code nor
one refusal, no browser or a failed way back are OidcCallbackExceptions.

halkyon_app's LoopbackWebAuth moves in with its tests, relicensed from
AGPL v3 to BSD-3-Clause by its author, the developer; GitHub's sign-in
keeps using it from there until it ends. The package is BSD-3-Clause,
publish_to: none, as the module (ADR 0023, D2, D3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 23:46:52 +02:00
commit 3cb2000927
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 706 additions and 42 deletions

View file

@ -59,7 +59,8 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
fails a sign-out.
- **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`;
`ServerpodAuthGateway` signs in anonymously and with GitHub (the system
browser and a loopback address on Linux and Windows, `LoopbackWebAuth`),
browser and a loopback address on Linux and Windows, halkyon_oidc_flutter's
`LoopbackWebAuth`),
through the instance's `halkyon_base`, and puts a session granted elsewhere
in place (`useSession`); `SessionBloc`, `GitHubSignInBloc` and
`SignInWaysCubit`; `SignedOutMark`, `SignInAddresses`, `PersonFolders`.

View file

@ -35,7 +35,6 @@ export 'src/auth/bloc/session_bloc.dart';
export 'src/auth/bloc/sign_in_ways_cubit.dart';
export 'src/auth/data/auth_gateway.dart';
export 'src/auth/data/auth_repository.dart';
export 'src/auth/data/loopback_web_auth.dart';
export 'src/auth/data/serverpod_auth_gateway.dart';
export 'src/auth/data/sign_in_addresses.dart';
export 'src/auth/data/signed_out_mark.dart';

View file

@ -1,149 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_to_front/window_to_front.dart';
/// The browser half of signing in with GitHub on Linux and Windows: opens
/// GitHub's page in the system browser and waits on a loopback address until
/// the browser comes back (RFC 8252, section 7.3).
///
/// It takes the place of flutter_web_auth_2's own implementation, which the
/// package's front ends whenever the app is resumed. That suits mobile, where
/// coming back to the app without finishing means giving up, but on the
/// desktop the app's window regaining focus resumes it, and the browser came
/// back to a closed port. Remove this class once the package no longer ends
/// the sign-in on Linux and Windows then.
///
/// The wait ends when the browser comes back, on [cancel], when another
/// sign-in starts, or after [_timeout].
class LoopbackWebAuth({
/// The app's name, as the browser's page says to go back to it.
final String appName = 'Halkyon',
final Future<bool> Function(Uri url) _launch = launchUrl,
final Future<void> Function() _bringToFront = WindowToFront.activate,
final Duration _timeout = const Duration(minutes: 5),
}) extends FlutterWebAuth2Platform {
HttpServer? _server;
Completer<String>? _pending;
/// Opens [url] in the browser and returns the URL the browser comes back
/// to. [callbackUrlScheme] is the whole redirect URI, on 127.0.0.1 with a
/// port; only its path ends the wait. [options] are the package's and
/// don't apply.
@override
Future<String> authenticate({
required String url,
required String callbackUrlScheme,
required Map<String, dynamic> options,
}) async {
final callback = Uri.parse(callbackUrlScheme);
if (callback.scheme != 'http' ||
callback.host != InternetAddress.loopbackIPv4.address ||
!callback.hasPort ||
callback.path.isEmpty) {
throw ArgumentError.value(
callbackUrlScheme,
'callbackUrlScheme',
'must be a path on http://127.0.0.1 with a port',
);
}
await cancel();
// The loopback address only: nothing but this device can answer.
final server = _server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
callback.port,
);
final pending = _pending = Completer<String>()..future.ignore();
server.listen((request) => _answer(request, callback.path, pending));
try {
if (!await _launch(Uri.parse(url))) {
throw PlatformException(
code: 'FAILED',
message: "Couldn't open the browser.",
);
}
final result = await pending.future.timeout(_timeout);
try {
await _bringToFront();
} on Exception catch (error, st) {
// The sign-in worked; the person switches back by themselves.
log(
"Couldn't bring the window to the front",
name: 'Auth',
error: error,
stackTrace: st,
);
}
return result;
} finally {
await server.close();
if (identical(_server, server)) {
_server = null;
_pending = null;
}
}
}
/// Does nothing: the package's front calls it whenever the app is resumed,
/// which on the desktop includes the window regaining focus. [cancel] ends
/// a sign-in instead.
@override
Future<void> clearAllDanglingCalls() async {}
/// Stops waiting: the sign-in in progress ends as cancelled, the way the
/// package reports it. Does nothing when none is in progress.
Future<void> cancel() async {
final pending = _pending;
final server = _server;
_pending = null;
_server = null;
if (pending != null && !pending.isCompleted) {
pending.completeError(
PlatformException(code: 'CANCELED', message: 'User canceled login'),
);
}
await server?.close(force: true);
}
/// Answers the browser: the first request for the callback's path ends the
/// wait, anything else is not found.
Future<void> _answer(
HttpRequest request,
String callbackPath,
Completer<String> pending,
) async {
final response = request.response;
if (request.uri.path == callbackPath && !pending.isCompleted) {
pending.complete(request.requestedUri.toString());
response
..headers.contentType = ContentType.html
..write(_landingPage(appName));
} else {
response.statusCode = HttpStatus.notFound;
}
try {
await response.close();
} on IOException {
// The browser went away before the answer; nothing is left to tell it.
}
}
}
/// What the browser shows once it has come back to [app].
String _landingPage(String app) {
final name = const HtmlEscape().convert(app);
return '''
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<title>$name</title>
<p>You can close this tab and return to $name.</p>
</html>
''';
}

View file

@ -4,7 +4,6 @@ import 'dart:io';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/data/loopback_web_auth.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
@ -14,6 +13,8 @@ import 'package:halkyon_app/src/base/base_client.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart'
show GitHubSignInRefusal, GitHubSignInRefusedException;
import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'
show LoopbackWebAuth;
import 'package:http/http.dart' as http;
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';

View file

@ -29,8 +29,9 @@ dependencies:
# The account's private keys, once unlocked, in the system's keyring
# (ADR 0017), as Serverpod keeps sessions.
flutter_secure_storage: ^10.0.0
# LoopbackWebAuth takes the place of the package's own browser half on
# Linux and Windows.
# GitHub's sign-in goes through the package's front, so LoopbackWebAuth
# (halkyon_oidc_flutter) takes the place of its browser half on Linux and
# Windows.
flutter_web_auth_2_platform_interface: ^5.0.0
halkyon_base_client:
path: ../halkyon_base_client
@ -40,6 +41,8 @@ dependencies:
# hook compiles, so every build of an app needs a C compiler and make.
halkyon_keys:
path: ../halkyon_keys
halkyon_oidc_flutter:
path: ../halkyon_oidc_flutter
halkyon_ui:
path: ../halkyon_ui
# A client of halkyon_base alone can be given a fake one in tests.
@ -63,7 +66,6 @@ dependencies:
# opening it on each isolate the account's keys are worked on.
sodium: 4.1.0+1
url_launcher: ^6.3.2
window_to_front: ^0.0.4
dev_dependencies:
fake_async: ^1.3.3

View file

@ -1,259 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show AppLifecycleState;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:halkyon_app/halkyon_app.dart';
void main() {
const gitHubPage = 'https://github.com/login/oauth/authorize?client_id=app';
late int port;
late String callback;
late Completer<Uri> pageOpened;
late int broughtToFront;
late LoopbackWebAuth webAuth;
/// The loopback part with a fake browser that only records the page it
/// opens; [opens] is whether opening it works.
LoopbackWebAuth loopback({
bool opens = true,
Future<void> Function()? bringToFront,
Duration timeout = const Duration(minutes: 1),
}) => LoopbackWebAuth(
appName: 'Halkyon Learn',
launch: (url) {
pageOpened.complete(url);
return Future.value(opens);
},
bringToFront:
bringToFront ??
() {
broughtToFront++;
return Future.value();
},
timeout: timeout,
);
setUp(() async {
// A port that is free right now, so the tests never meet a running app.
final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
port = probe.port;
await probe.close();
callback = 'http://127.0.0.1:$port/github-callback';
pageOpened = Completer();
broughtToFront = 0;
webAuth = loopback();
});
tearDown(() => webAuth.cancel());
/// Starts a sign-in as flutter_web_auth_2 hands it over.
Future<String> signIn() => webAuth.authenticate(
url: gitHubPage,
callbackUrlScheme: callback,
options: const {},
);
/// The browser asking this machine's [address] for [pathAndQuery], as when
/// GitHub sends it back. Over a raw socket, so that the fake HTTP of
/// Flutter's test binding can't get in between.
Future<({int status, String body})> browserAsks(
String pathAndQuery, {
InternetAddress? address,
}) async {
final socket = await Socket.connect(
address ?? InternetAddress.loopbackIPv4,
port,
timeout: const Duration(seconds: 2),
);
socket.write(
'GET $pathAndQuery HTTP/1.1\r\n'
'Host: 127.0.0.1:$port\r\n'
'Connection: close\r\n\r\n',
);
final response = await utf8.decoder.bind(socket).join();
socket.destroy();
return (
status: int.parse(response.split(' ')[1]),
body: response.substring(response.indexOf('\r\n\r\n') + 4),
);
}
test(
"opens GitHub's page and returns where the browser comes back to",
() async {
final result = signIn();
expect(await pageOpened.future, Uri.parse(gitHubPage));
final response = await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(response.body, contains('return to Halkyon Learn'));
expect(broughtToFront, 1);
},
);
test(
"keeps waiting when the app's window regains focus",
() async {
// The way the bug went: flutter_web_auth_2's front ends every pending
// sign-in when the app is resumed, and on the desktop the window
// regaining focus resumes it.
TestWidgetsFlutterBinding.ensureInitialized();
FlutterWebAuth2Platform.instance = webAuth;
final result = FlutterWebAuth2.authenticate(
url: gitHubPage,
callbackUrlScheme: callback,
);
await pageOpened.future;
// The browser takes the focus; then the person looks back at the app.
await _embedderReports(AppLifecycleState.inactive);
await _embedderReports(AppLifecycleState.resumed);
final response = await browserAsks('/github-callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
},
// Elsewhere, flutter_web_auth_2 insists on a URL scheme as the callback.
skip: !(Platform.isLinux || Platform.isWindows),
);
test('a cancel ends the wait as cancelled and stops listening', () async {
final result = signIn();
await pageOpened.future;
await webAuth.cancel();
await expectLater(result, throwsA(_cancelled));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('gives up after the timeout and stops listening', () async {
webAuth = loopback(timeout: const Duration(milliseconds: 50));
await expectLater(signIn(), throwsA(isA<TimeoutException>()));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('a second sign-in ends the first as cancelled', () async {
final first = signIn();
await pageOpened.future;
pageOpened = Completer();
final second = signIn();
await expectLater(first, throwsA(_cancelled));
await pageOpened.future;
await browserAsks('/github-callback?code=c&state=s');
expect(await second, '$callback?code=c&state=s');
});
test('answers other paths with "not found" and keeps waiting', () async {
final result = signIn();
await pageOpened.future;
final stray = await browserAsks('/favicon.ico');
final response = await browserAsks('/github-callback?code=c&state=s');
expect(stray.status, HttpStatus.notFound);
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
});
test('listens on the loopback address only', () async {
final otherAddresses = [
for (final interface in await NetworkInterface.list())
for (final address in interface.addresses)
if (!address.isLoopback) address,
];
final result = signIn();
await pageOpened.future;
for (final address in otherAddresses) {
await expectLater(
browserAsks('/github-callback?code=c&state=s', address: address),
throwsA(isA<SocketException>()),
reason: '$address',
);
}
await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test("stops listening when the browser can't be opened", () async {
webAuth = loopback(opens: false);
await expectLater(signIn(), throwsA(isA<PlatformException>()));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test("signs in even when the window can't come to the front", () async {
webAuth = loopback(
bringToFront: () => Future.error(MissingPluginException()),
);
final result = signIn();
await pageOpened.future;
await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test(
'refuses a callback other than a path on 127.0.0.1 with a port',
() async {
for (final address in [
'http://0.0.0.0:$port/github-callback',
'http://localhost:$port/github-callback',
'http://example.com:$port/github-callback',
'https://127.0.0.1:$port/github-callback',
'http://127.0.0.1/github-callback',
'http://127.0.0.1:$port',
]) {
await expectLater(
webAuth.authenticate(
url: gitHubPage,
callbackUrlScheme: address,
options: const {},
),
throwsArgumentError,
reason: address,
);
}
expect(pageOpened.isCompleted, isFalse);
},
);
}
/// How flutter_web_auth_2 reports a cancelled sign-in, which is how the
/// gateway's callers learn of it.
final _cancelled = isA<PlatformException>().having(
(error) => error.code,
'code',
'CANCELED',
);
/// The embedder reporting the app's lifecycle, as it does when the window
/// gains or loses focus.
Future<void> _embedderReports(AppLifecycleState state) =>
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
SystemChannels.lifecycle.name,
SystemChannels.lifecycle.codec.encodeMessage(state.toString()),
(_) {},
);