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
3 changed files with 706 additions and 42 deletions
259
packages/halkyon_oidc_flutter/test/loopback_web_auth_test.dart
Normal file
259
packages/halkyon_oidc_flutter/test/loopback_web_auth_test.dart
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
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_oidc_flutter/halkyon_oidc_flutter.dart';
|
||||
|
||||
void main() {
|
||||
const providerPage = 'https://id.example.org/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: 'My App',
|
||||
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/oidc-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: providerPage,
|
||||
callbackUrlScheme: callback,
|
||||
options: const {},
|
||||
);
|
||||
|
||||
/// The browser asking this machine's [address] for [pathAndQuery], as when
|
||||
/// the provider 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 the provider's page and returns where the browser comes back to",
|
||||
() async {
|
||||
final result = signIn();
|
||||
|
||||
expect(await pageOpened.future, Uri.parse(providerPage));
|
||||
final response = await browserAsks('/oidc-callback?code=c&state=s');
|
||||
|
||||
expect(await result, '$callback?code=c&state=s');
|
||||
expect(response.status, HttpStatus.ok);
|
||||
expect(response.body, contains('return to My App'));
|
||||
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: providerPage,
|
||||
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('/oidc-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('/oidc-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('/oidc-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('/oidc-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('/oidc-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('/oidc-callback?code=c&state=s', address: address),
|
||||
throwsA(isA<SocketException>()),
|
||||
reason: '$address',
|
||||
);
|
||||
}
|
||||
await browserAsks('/oidc-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('/oidc-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('/oidc-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/oidc-callback',
|
||||
'http://localhost:$port/oidc-callback',
|
||||
'http://example.com:$port/oidc-callback',
|
||||
'https://127.0.0.1:$port/oidc-callback',
|
||||
'http://127.0.0.1/oidc-callback',
|
||||
'http://127.0.0.1:$port',
|
||||
]) {
|
||||
await expectLater(
|
||||
webAuth.authenticate(
|
||||
url: providerPage,
|
||||
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
|
||||
/// browser half'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()),
|
||||
(_) {},
|
||||
);
|
||||
184
packages/halkyon_oidc_flutter/test/oidc_browser_test.dart
Normal file
184
packages/halkyon_oidc_flutter/test/oidc_browser_test.dart
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import 'dart:async';
|
||||
|
||||
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:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
|
||||
|
||||
void main() {
|
||||
const state = 'app-scheme.s1';
|
||||
final start = OidcStart(
|
||||
attempt: UuidValue.fromString('5b9d7f2e-0c1a-4e3b-9f6d-2a8c4e1b7d30'),
|
||||
authorizationUrl: Uri.parse(
|
||||
'https://id.example.org/authorize?client_id=app&state=$state',
|
||||
),
|
||||
state: state,
|
||||
);
|
||||
|
||||
late List<({String url, String callback, FlutterWebAuth2Options options})>
|
||||
opened;
|
||||
late FutureOr<String> Function() comesBack;
|
||||
late _FakeLoopback loopback;
|
||||
late OidcBrowser browser;
|
||||
|
||||
setUp(() {
|
||||
opened = [];
|
||||
comesBack = () => 'my.app://oidc-callback?code=c&state=$state';
|
||||
loopback = _FakeLoopback();
|
||||
browser = OidcBrowser(
|
||||
loopback: loopback,
|
||||
authenticate:
|
||||
({
|
||||
required url,
|
||||
required callbackUrlScheme,
|
||||
options = const FlutterWebAuth2Options(),
|
||||
}) async {
|
||||
opened.add((
|
||||
url: url,
|
||||
callback: callbackUrlScheme,
|
||||
options: options,
|
||||
));
|
||||
return await comesBack();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('by the scheme', () {
|
||||
test("opens the provider's page and returns the code", () async {
|
||||
final back = await browser.open(start, const OidcReturn.scheme('my.app'));
|
||||
|
||||
expect(back, const OidcCode('c'));
|
||||
expect(opened.single.url, start.authorizationUrl.toString());
|
||||
expect(opened.single.callback, 'my.app');
|
||||
expect(loopback.waits, isEmpty);
|
||||
});
|
||||
|
||||
test("returns the provider's refusal", () async {
|
||||
comesBack = () => 'my.app://oidc-callback?error=deny&state=$state';
|
||||
|
||||
expect(
|
||||
await browser.open(start, const OidcReturn.scheme('my.app')),
|
||||
const OidcDenied('deny'),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns nothing when the person closed the browser', () async {
|
||||
comesBack = () => throw PlatformException(code: 'CANCELED');
|
||||
|
||||
expect(
|
||||
await browser.open(start, const OidcReturn.scheme('my.app')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('by https, names the host and the path the system catches', () async {
|
||||
comesBack = () => 'https://example.org/oidc-callback?code=c&state=$state';
|
||||
|
||||
final back = await browser.open(
|
||||
start,
|
||||
const OidcReturn.https(host: 'example.org', path: '/oidc-callback'),
|
||||
);
|
||||
|
||||
expect(back, const OidcCode('c'));
|
||||
expect(opened.single.callback, 'https');
|
||||
expect(opened.single.options.httpsHost, 'example.org');
|
||||
expect(opened.single.options.httpsPath, '/oidc-callback');
|
||||
});
|
||||
|
||||
group('by the loopback', () {
|
||||
const back = OidcReturn.loopback(port: 43823, path: '/oidc-callback');
|
||||
|
||||
test('waits on the loopback address, not through the package', () async {
|
||||
loopback.comesBack = () =>
|
||||
'http://127.0.0.1:43823/oidc-callback?code=c&state=$state';
|
||||
|
||||
expect(await browser.open(start, back), const OidcCode('c'));
|
||||
expect(loopback.waits.single, (
|
||||
url: start.authorizationUrl.toString(),
|
||||
callback: 'http://127.0.0.1:43823/oidc-callback',
|
||||
));
|
||||
expect(opened, isEmpty);
|
||||
});
|
||||
|
||||
test('returns nothing once the wait is cancelled or runs out', () async {
|
||||
for (final error in [
|
||||
PlatformException(code: 'CANCELED'),
|
||||
TimeoutException('5 minutes'),
|
||||
]) {
|
||||
loopback.comesBack = () => throw error;
|
||||
|
||||
expect(await browser.open(start, back), isNull, reason: '$error');
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel stops the wait', () async {
|
||||
await browser.cancel();
|
||||
|
||||
expect(loopback.cancelled, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('refuses what came back for another sign-in', () async {
|
||||
comesBack = () => 'my.app://oidc-callback?code=c&state=app-scheme.other';
|
||||
|
||||
await expectLater(
|
||||
browser.open(start, const OidcReturn.scheme('my.app')),
|
||||
throwsA(_problem(OidcCallbackProblem.wrongState)),
|
||||
);
|
||||
});
|
||||
|
||||
test("says when there's no browser to open the page in", () async {
|
||||
for (final code in ['NO_BROWSER', 'SECURITY_EXCEPTION']) {
|
||||
comesBack = () => throw PlatformException(code: code);
|
||||
|
||||
await expectLater(
|
||||
browser.open(start, const OidcReturn.scheme('my.app')),
|
||||
throwsA(_problem(OidcCallbackProblem.noBrowser)),
|
||||
reason: code,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('says when the way back failed otherwise', () async {
|
||||
for (final code in ['FAILED', 'CANNOT_RESTORE', 'UNKNOWN']) {
|
||||
comesBack = () => throw PlatformException(code: code);
|
||||
|
||||
await expectLater(
|
||||
browser.open(start, const OidcReturn.scheme('my.app')),
|
||||
throwsA(_problem(OidcCallbackProblem.failed)),
|
||||
reason: code,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The loopback with no port and no browser: records what it is asked to
|
||||
/// wait for and answers with [comesBack].
|
||||
class _FakeLoopback() implements LoopbackWebAuth {
|
||||
final waits = <({String url, String callback})>[];
|
||||
FutureOr<String> Function() comesBack = () => throw UnimplementedError();
|
||||
int cancelled = 0;
|
||||
|
||||
@override
|
||||
String get appName => 'My App';
|
||||
|
||||
@override
|
||||
Future<String> authenticate({
|
||||
required String url,
|
||||
required String callbackUrlScheme,
|
||||
required Map<String, dynamic> options,
|
||||
}) async {
|
||||
waits.add((url: url, callback: callbackUrlScheme));
|
||||
return await comesBack();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel() async => cancelled++;
|
||||
|
||||
@override
|
||||
Future<void> clearAllDanglingCalls() async {}
|
||||
}
|
||||
|
||||
Matcher _problem(OidcCallbackProblem problem) => isA<OidcCallbackException>()
|
||||
.having((error) => error.problem, 'problem', problem);
|
||||
80
packages/halkyon_oidc_flutter/test/oidc_callback_test.dart
Normal file
80
packages/halkyon_oidc_flutter/test/oidc_callback_test.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
|
||||
|
||||
void main() {
|
||||
const state = 'app-scheme.q3J0aGVyZQ';
|
||||
|
||||
OidcCallback parse(String query, {String base = 'my.app://oidc-callback'}) =>
|
||||
OidcCallback.parse('$base?$query', state: state);
|
||||
|
||||
test('reads the code that came back for the sign-in', () {
|
||||
expect(parse('code=abc&state=$state'), const OidcCode('abc'));
|
||||
});
|
||||
|
||||
test('reads it from the loopback and from an https address alike', () {
|
||||
for (final base in [
|
||||
'http://127.0.0.1:43823/oidc-callback',
|
||||
'https://example.org/oidc-callback',
|
||||
]) {
|
||||
expect(
|
||||
parse('state=$state&code=abc', base: base),
|
||||
const OidcCode('abc'),
|
||||
reason: base,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("reads the provider's refusal", () {
|
||||
expect(
|
||||
parse('error=access_denied&state=$state'),
|
||||
const OidcDenied('access_denied'),
|
||||
);
|
||||
// SimpleLogin's own.
|
||||
expect(parse('error=deny&state=$state'), const OidcDenied('deny'));
|
||||
});
|
||||
|
||||
test('refuses what came back for another sign-in, or for none', () {
|
||||
for (final query in [
|
||||
'code=abc&state=app-scheme.other',
|
||||
'code=abc&state=',
|
||||
'code=abc',
|
||||
'error=deny&state=app-scheme.other',
|
||||
'error=deny',
|
||||
]) {
|
||||
expect(
|
||||
() => parse(query),
|
||||
throwsA(_problem(OidcCallbackProblem.wrongState)),
|
||||
reason: query,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses what is neither a code nor a refusal', () {
|
||||
for (final query in [
|
||||
'state=$state',
|
||||
'code=&state=$state',
|
||||
'error=&state=$state',
|
||||
'code=abc&error=deny&state=$state',
|
||||
'code=a&code=b&state=$state',
|
||||
'error=deny&error=deny&state=$state',
|
||||
'code=abc&state=$state&state=$state',
|
||||
]) {
|
||||
expect(
|
||||
() => parse(query),
|
||||
throwsA(_problem(OidcCallbackProblem.malformed)),
|
||||
reason: query,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses an address that can't be read", () {
|
||||
// A UTF-8 sequence cut short.
|
||||
expect(
|
||||
() => parse('code=%E0%A4&state=$state'),
|
||||
throwsA(_problem(OidcCallbackProblem.malformed)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Matcher _problem(OidcCallbackProblem problem) => isA<OidcCallbackException>()
|
||||
.having((error) => error.problem, 'problem', problem);
|
||||
Loading…
Reference in a new issue