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>
184 lines
5.5 KiB
Dart
184 lines
5.5 KiB
Dart
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);
|