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:
parent
7ea9f16904
commit
3cb2000927
5 changed files with 706 additions and 42 deletions
12
packages/halkyon_oidc_flutter/lib/halkyon_oidc_flutter.dart
Normal file
12
packages/halkyon_oidc_flutter/lib/halkyon_oidc_flutter.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/// The device's half of signing in with OpenID Connect on halkyon_oidc:
|
||||
/// the provider's page opened in the browser, the way back caught and
|
||||
/// checked against the sign-in that began it.
|
||||
library;
|
||||
|
||||
export 'package:halkyon_oidc_client/halkyon_oidc_client.dart'
|
||||
show OidcRefusal, OidcRefusedException, OidcStart, UuidValue;
|
||||
|
||||
export 'src/loopback_web_auth.dart';
|
||||
export 'src/oidc_browser.dart';
|
||||
export 'src/oidc_callback.dart';
|
||||
export 'src/oidc_return.dart';
|
||||
151
packages/halkyon_oidc_flutter/lib/src/loopback_web_auth.dart
Normal file
151
packages/halkyon_oidc_flutter/lib/src/loopback_web_auth.dart
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
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 on Linux and Windows: opens the provider'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. `OidcBrowser` calls it directly; a sign-in that
|
||||
/// goes through the package's front (Serverpod's own, with PKCE) puts it in
|
||||
/// place as `FlutterWebAuth2Platform.instance`. 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.
|
||||
required final String appName,
|
||||
|
||||
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>
|
||||
''';
|
||||
}
|
||||
76
packages/halkyon_oidc_flutter/lib/src/oidc_browser.dart
Normal file
76
packages/halkyon_oidc_flutter/lib/src/oidc_browser.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart';
|
||||
import 'package:halkyon_oidc_flutter/src/loopback_web_auth.dart';
|
||||
import 'package:halkyon_oidc_flutter/src/oidc_callback.dart';
|
||||
import 'package:halkyon_oidc_flutter/src/oidc_return.dart';
|
||||
|
||||
/// Opens a page in the system's browser sheet and returns the address it
|
||||
/// comes back to: flutter_web_auth_2's `authenticate`.
|
||||
typedef WebAuthenticate = Future<String> Function({
|
||||
required String url,
|
||||
required String callbackUrlScheme,
|
||||
FlutterWebAuth2Options options,
|
||||
});
|
||||
|
||||
/// The device's half of signing in with OpenID Connect: opens the
|
||||
/// provider's page for an attempt the server began (`OidcStart`), waits
|
||||
/// until the browser comes back, and checks that it came back for that
|
||||
/// attempt. The code it returns goes to the server, with the attempt's ID,
|
||||
/// to end the sign-in.
|
||||
class OidcBrowser({
|
||||
/// Waits on the loopback for [OidcReturn.loopback], in place of
|
||||
/// flutter_web_auth_2's own; its page names the app.
|
||||
required final LoopbackWebAuth loopback,
|
||||
|
||||
final WebAuthenticate _authenticate = FlutterWebAuth2.authenticate,
|
||||
}) {
|
||||
/// Opens [start]'s page and waits until the browser comes [back]: the
|
||||
/// provider's code or its refusal, or null when the person closed the
|
||||
/// browser, or the loopback's wait was cancelled or ran out. Throws an
|
||||
/// [OidcCallbackException] when it came back for another sign-in, came
|
||||
/// back with neither, or the browser failed.
|
||||
Future<OidcCallback?> open(OidcStart start, OidcReturn back) async {
|
||||
final url = start.authorizationUrl.toString();
|
||||
final String address;
|
||||
try {
|
||||
address = await switch (back) {
|
||||
OidcSchemeReturn(:final scheme) => _authenticate(
|
||||
url: url,
|
||||
callbackUrlScheme: scheme,
|
||||
),
|
||||
OidcHttpsReturn(:final host, :final path) => _authenticate(
|
||||
url: url,
|
||||
callbackUrlScheme: 'https',
|
||||
options: FlutterWebAuth2Options(httpsHost: host, httpsPath: path),
|
||||
),
|
||||
// Straight to the loopback, not through the package's front, which
|
||||
// would end the wait whenever the window regains focus.
|
||||
final OidcLoopbackReturn loopbackReturn => loopback.authenticate(
|
||||
url: url,
|
||||
callbackUrlScheme: loopbackReturn.address,
|
||||
options: const {},
|
||||
),
|
||||
};
|
||||
} on PlatformException catch (error) {
|
||||
// flutter_web_auth_2's codes (checked with v5.1.0).
|
||||
return switch (error.code) {
|
||||
'CANCELED' => null,
|
||||
'NO_BROWSER' ||
|
||||
'SECURITY_EXCEPTION' => throw const OidcCallbackException(
|
||||
OidcCallbackProblem.noBrowser,
|
||||
),
|
||||
_ => throw const OidcCallbackException(OidcCallbackProblem.failed),
|
||||
};
|
||||
} on TimeoutException {
|
||||
return null;
|
||||
}
|
||||
return OidcCallback.parse(address, state: start.state);
|
||||
}
|
||||
|
||||
/// Stops waiting on the loopback: the sign-in in progress returns nothing.
|
||||
/// The system's browser sheet ends by itself when the person closes it.
|
||||
Future<void> cancel() => loopback.cancel();
|
||||
}
|
||||
78
packages/halkyon_oidc_flutter/lib/src/oidc_callback.dart
Normal file
78
packages/halkyon_oidc_flutter/lib/src/oidc_callback.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What the browser came back with from the provider, for the sign-in that
|
||||
/// began it.
|
||||
sealed class const OidcCallback() with Equatable {
|
||||
/// Reads [url], where the browser came back to, for the sign-in whose
|
||||
/// state is [state]: the code, or the provider's refusal. Throws an
|
||||
/// [OidcCallbackException] for another sign-in's state, or none
|
||||
/// ([OidcCallbackProblem.wrongState]), and for anything but one code or one
|
||||
/// refusal ([OidcCallbackProblem.malformed]).
|
||||
factory parse(String url, {required String state}) {
|
||||
final Map<String, List<String>> query;
|
||||
try {
|
||||
query = Uri.parse(url).queryParametersAll;
|
||||
} on FormatException {
|
||||
throw const OidcCallbackException(OidcCallbackProblem.malformed);
|
||||
}
|
||||
final states = query['state'] ?? const [];
|
||||
if (states.length > 1) {
|
||||
throw const OidcCallbackException(OidcCallbackProblem.malformed);
|
||||
}
|
||||
// The state is what ties the way back to this sign-in: the provider
|
||||
// repeats it, and the server never compares it.
|
||||
if (states.singleOrNull != state) {
|
||||
throw const OidcCallbackException(OidcCallbackProblem.wrongState);
|
||||
}
|
||||
return switch ((query['code'], query['error'])) {
|
||||
([final code], null) when code.isNotEmpty => OidcCode(code),
|
||||
(null, [final error]) when error.isNotEmpty => OidcDenied(error),
|
||||
_ => throw const OidcCallbackException(OidcCallbackProblem.malformed),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider's code, for the server to end the sign-in with.
|
||||
final class const OidcCode(
|
||||
/// Worthless without the sign-in's attempt, which only this device holds.
|
||||
final String code,
|
||||
) extends OidcCallback {
|
||||
@override
|
||||
List<Object?> get props => [code];
|
||||
|
||||
@override
|
||||
String toString() => 'OidcCode(…)';
|
||||
}
|
||||
|
||||
/// The provider's refusal: the person said no, or the provider couldn't go
|
||||
/// on.
|
||||
final class const OidcDenied(
|
||||
/// OAuth's error code (`access_denied`; SimpleLogin's own is `deny`).
|
||||
final String error,
|
||||
) extends OidcCallback {
|
||||
@override
|
||||
List<Object?> get props => [error];
|
||||
}
|
||||
|
||||
/// Why the way back from the provider can't go on.
|
||||
enum OidcCallbackProblem() {
|
||||
/// The browser came back for another sign-in, or for none.
|
||||
wrongState,
|
||||
|
||||
/// It came back with neither one code nor one refusal.
|
||||
malformed,
|
||||
|
||||
/// There's no browser to open the provider's page in.
|
||||
noBrowser,
|
||||
|
||||
/// The browser couldn't be opened, or didn't come back as asked: the
|
||||
/// system's sheet failed, or the app was ended meanwhile.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// The way back from the provider failed, for [problem].
|
||||
final class const OidcCallbackException(final OidcCallbackProblem problem)
|
||||
implements Exception {
|
||||
@override
|
||||
String toString() => 'OidcCallbackException(${problem.name})';
|
||||
}
|
||||
38
packages/halkyon_oidc_flutter/lib/src/oidc_return.dart
Normal file
38
packages/halkyon_oidc_flutter/lib/src/oidc_return.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/// How the browser comes back to this device from the provider (RFC 8252,
|
||||
/// 7): the redirect the server's attempt names, or the address its
|
||||
/// forwarding page sends the browser on to.
|
||||
sealed class const OidcReturn() {
|
||||
/// The app's own scheme (`my.app://oidc-callback`), which the system's
|
||||
/// browser sheet catches: Chrome's Auth Tab or a Custom Tab on Android,
|
||||
/// `ASWebAuthenticationSession` on iOS and macOS.
|
||||
const factory scheme(String scheme) = OidcSchemeReturn;
|
||||
|
||||
/// A loopback address on this machine (`http://127.0.0.1:<port><path>`),
|
||||
/// on which `OidcBrowser` waits itself: Linux and Windows.
|
||||
const factory loopback({required int port, required String path}) =
|
||||
OidcLoopbackReturn;
|
||||
|
||||
/// An https address the system hands to the app, as it is verified as
|
||||
/// the app's: Android's App Links (with Chrome's Auth Tab), and iOS's and
|
||||
/// macOS's associated domains (v17.4 and v14.4 on).
|
||||
const factory https({required String host, required String path}) =
|
||||
OidcHttpsReturn;
|
||||
}
|
||||
|
||||
/// The way back by the app's [scheme].
|
||||
final class const OidcSchemeReturn(final String scheme) extends OidcReturn;
|
||||
|
||||
/// The way back by the loopback, on [port] at [path].
|
||||
final class const OidcLoopbackReturn({
|
||||
required final int port,
|
||||
required final String path,
|
||||
}) extends OidcReturn {
|
||||
/// The whole address the browser comes back to.
|
||||
String get address => 'http://127.0.0.1:$port$path';
|
||||
}
|
||||
|
||||
/// The way back by a verified https address, [host] and [path].
|
||||
final class const OidcHttpsReturn({
|
||||
required final String host,
|
||||
required final String path,
|
||||
}) extends OidcReturn;
|
||||
Loading…
Reference in a new issue