fix(app): GitHub sign-in on Linux and Windows waits for the browser
Signing in with GitHub on Linux ended in "Unable to connect" at 127.0.0.1:43823 after authorizing. flutter_web_auth_2 (v5.1.0, unchanged on its master) ends every pending sign-in when the app is resumed. That suits mobile, where coming back without finishing means giving up, but on the desktop the window regaining focus resumes the app, and the package's loopback server closed before GitHub sent the browser back. FluffyChat's issue #3341 and the package's #171 report the same. The app now puts its own Linux and Windows implementation behind the package's platform interface (LoopbackWebAuth): the system browser and a loopback address, as RFC 8252 recommends, waiting until GitHub answers, the person cancels, or five minutes pass. It listens on 127.0.0.1 only and answers only the callback's path. Serverpod's PKCE and state check stay in use. There, the account dialog shows "Continue in your browser." with a Cancel button, and closing the dialog cancels the sign-in. Serverpod (v4.0.3) reports a cancelled browser round trip as an unknown error, so the gateway knows a cancel by having asked for it. A cancel on GitHub's own page, or leaving the browser on Android, still reads as a failure; AGENTS.md says so. The regression test drives the package's front through a change of focus. With the package's behaviour it fails with the same refused connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
807dbbfa06
commit
4ab78d5547
12 changed files with 703 additions and 8 deletions
|
|
@ -111,6 +111,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.info()`.
|
- **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.info()`.
|
||||||
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
|
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
|
||||||
- **Switching devices' person** (the app's `switchToGitHub`): swap the session directly, never through "signed out" (which would start another anonymous account), then end the anonymous session left behind.
|
- **Switching devices' person** (the app's `switchToGitHub`): swap the session directly, never through "signed out" (which would start another anonymous account), then end the anonymous session left behind.
|
||||||
|
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, `feature/auth/data/`): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the dialog's Cancel (shown only there), or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
|
||||||
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
|
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
|
||||||
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
|
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
|
||||||
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,19 @@ class GitHubSignInBloc(
|
||||||
on<GitHubSignInEventSwitchDeclined>(
|
on<GitHubSignInEventSwitchDeclined>(
|
||||||
(event, emit) => emit(const GitHubSignInStateReady()),
|
(event, emit) => emit(const GitHubSignInStateReady()),
|
||||||
);
|
);
|
||||||
|
// The sign-in in progress then ends as cancelled.
|
||||||
|
on<GitHubSignInEventCancelled>((event, emit) => _auth.cancelGitHub());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closing the account dialog cancels a sign-in still waiting for GitHub's
|
||||||
|
/// page (decided 2026-09-30): nothing finishes without the dialog to show
|
||||||
|
/// it, such as the question whether to switch.
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
if (state is GitHubSignInStateBusy) {
|
||||||
|
await _auth.cancelGitHub();
|
||||||
|
}
|
||||||
|
await super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onStarted(
|
Future<void> _onStarted(
|
||||||
|
|
@ -44,7 +57,7 @@ class GitHubSignInBloc(
|
||||||
GitHubSignInEventRequested event,
|
GitHubSignInEventRequested event,
|
||||||
Emitter<GitHubSignInState> emit,
|
Emitter<GitHubSignInState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(const GitHubSignInStateBusy());
|
emit(GitHubSignInStateBusy(cancellable: _auth.canCancelGitHub));
|
||||||
try {
|
try {
|
||||||
switch (await _auth.linkGitHub()) {
|
switch (await _auth.linkGitHub()) {
|
||||||
case GitHubLinkOutcome.linked:
|
case GitHubLinkOutcome.linked:
|
||||||
|
|
@ -69,7 +82,7 @@ class GitHubSignInBloc(
|
||||||
GitHubSignInEventSwitchConfirmed event,
|
GitHubSignInEventSwitchConfirmed event,
|
||||||
Emitter<GitHubSignInState> emit,
|
Emitter<GitHubSignInState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(const GitHubSignInStateBusy());
|
emit(GitHubSignInStateBusy(cancellable: _auth.canCancelGitHub));
|
||||||
try {
|
try {
|
||||||
await _switch(emit);
|
await _switch(emit);
|
||||||
} on Exception catch (error, st) {
|
} on Exception catch (error, st) {
|
||||||
|
|
@ -106,6 +119,9 @@ final class const GitHubSignInEventSwitchConfirmed() extends GitHubSignInEvent;
|
||||||
/// Stay anonymous.
|
/// Stay anonymous.
|
||||||
final class const GitHubSignInEventSwitchDeclined() extends GitHubSignInEvent;
|
final class const GitHubSignInEventSwitchDeclined() extends GitHubSignInEvent;
|
||||||
|
|
||||||
|
/// Stop waiting for GitHub's page in the browser.
|
||||||
|
final class const GitHubSignInEventCancelled() extends GitHubSignInEvent;
|
||||||
|
|
||||||
/// Signing in with GitHub, as the account dialog shows it.
|
/// Signing in with GitHub, as the account dialog shows it.
|
||||||
sealed class const GitHubSignInState() with Equatable {
|
sealed class const GitHubSignInState() with Equatable {
|
||||||
@override
|
@override
|
||||||
|
|
@ -128,7 +144,14 @@ final class const GitHubSignInStateReady({
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signing in: GitHub's page is open, or the server is being asked.
|
/// Signing in: GitHub's page is open, or the server is being asked.
|
||||||
final class const GitHubSignInStateBusy() extends GitHubSignInState;
|
final class const GitHubSignInStateBusy({
|
||||||
|
/// Whether the app offers to cancel, where no sign-in screen of the system
|
||||||
|
/// does. A cancel only stops the wait for GitHub's page.
|
||||||
|
final bool cancellable = false,
|
||||||
|
}) extends GitHubSignInState {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [cancellable];
|
||||||
|
}
|
||||||
|
|
||||||
/// The GitHub account belongs to another person and this device holds
|
/// The GitHub account belongs to another person and this device holds
|
||||||
/// studysets: switch and leave them behind, or stay anonymous?
|
/// studysets: switch and leave them behind, or stay anonymous?
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,14 @@ abstract interface class AuthGateway() {
|
||||||
/// Authorizes with GitHub again and switches this device to the person who
|
/// Authorizes with GitHub again and switches this device to the person who
|
||||||
/// has that GitHub account; false when the person cancelled on GitHub.
|
/// has that GitHub account; false when the person cancelled on GitHub.
|
||||||
Future<bool> switchToGitHub();
|
Future<bool> switchToGitHub();
|
||||||
|
|
||||||
|
/// Whether the app itself waits for GitHub's page in the browser, so that
|
||||||
|
/// only the app can cancel it (Linux, Windows). Elsewhere the system's
|
||||||
|
/// sign-in screen has its own Cancel.
|
||||||
|
bool get canCancelGitHub;
|
||||||
|
|
||||||
|
/// Stops waiting for GitHub's page in the browser: the [linkGitHub] or
|
||||||
|
/// [switchToGitHub] in progress ends as cancelled. Does nothing once
|
||||||
|
/// GitHub has answered.
|
||||||
|
Future<void> cancelGitHub();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,11 @@ class AuthRepository(final AuthGateway _gateway) {
|
||||||
/// Switches this device to the person who has the GitHub account. The
|
/// Switches this device to the person who has the GitHub account. The
|
||||||
/// anonymous account this device leaves behind can't be signed in again.
|
/// anonymous account this device leaves behind can't be signed in again.
|
||||||
Future<bool> switchToGitHub() => _gateway.switchToGitHub();
|
Future<bool> switchToGitHub() => _gateway.switchToGitHub();
|
||||||
|
|
||||||
|
/// Whether the app offers to cancel a GitHub sign-in in progress, where no
|
||||||
|
/// sign-in screen of the system does.
|
||||||
|
bool get canCancelGitHub => _gateway.canCancelGitHub;
|
||||||
|
|
||||||
|
/// Cancels the GitHub sign-in in progress while GitHub's page is open.
|
||||||
|
Future<void> cancelGitHub() => _gateway.cancelGitHub();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import 'dart:async';
|
||||||
|
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({
|
||||||
|
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);
|
||||||
|
} 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 the app.
|
||||||
|
const _landingPage = '''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Halkyon Learn</title>
|
||||||
|
<p>You can close this tab and return to Halkyon Learn.</p>
|
||||||
|
</html>
|
||||||
|
''';
|
||||||
|
|
@ -2,9 +2,11 @@ import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart'
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart'
|
||||||
show Client, GitHubSignInRefusal, GitHubSignInRefusedException;
|
show Client, GitHubSignInRefusal, GitHubSignInRefusedException;
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/loopback_web_auth.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||||
|
|
@ -24,17 +26,28 @@ class ServerpodAuthGateway(final Client _client) implements AuthGateway {
|
||||||
/// Where GitHub sends the browser back after authorizing: the app's own
|
/// Where GitHub sends the browser back after authorizing: the app's own
|
||||||
/// scheme on Android, iOS and macOS; on Linux and Windows a loopback address
|
/// scheme on Android, iOS and macOS; on Linux and Windows a loopback address
|
||||||
/// the app listens on meanwhile (GitHub accepts any port there, so the
|
/// the app listens on meanwhile (GitHub accepts any port there, so the
|
||||||
/// OAuth app registers `http://127.0.0.1/github-callback`).
|
/// OAuth app registers `http://127.0.0.1/github-callback`). There,
|
||||||
|
/// [LoopbackWebAuth] takes the whole address, to answer only its path.
|
||||||
static final _gitHubCallback = Platform.isLinux || Platform.isWindows
|
static final _gitHubCallback = Platform.isLinux || Platform.isWindows
|
||||||
? (
|
? (
|
||||||
redirectUri: 'http://127.0.0.1:43823/github-callback',
|
redirectUri: 'http://127.0.0.1:43823/github-callback',
|
||||||
urlScheme: 'http://127.0.0.1:43823',
|
urlScheme: 'http://127.0.0.1:43823/github-callback',
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
redirectUri: 'app.halkyon.learn://github-callback',
|
redirectUri: 'app.halkyon.learn://github-callback',
|
||||||
urlScheme: 'app.halkyon.learn',
|
urlScheme: 'app.halkyon.learn',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// The browser half of signing in with GitHub on Linux and Windows, in
|
||||||
|
/// place of flutter_web_auth_2's own, which stops waiting whenever the
|
||||||
|
/// window regains focus. Elsewhere, the package's.
|
||||||
|
final _loopback = Platform.isLinux || Platform.isWindows
|
||||||
|
? LoopbackWebAuth()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// Whether the person cancelled the GitHub sign-in in progress.
|
||||||
|
var _gitHubCancelled = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AuthStatus get status => switch (_client.auth.authInfo) {
|
AuthStatus get status => switch (_client.auth.authInfo) {
|
||||||
null => const SignedOut(),
|
null => const SignedOut(),
|
||||||
|
|
@ -129,9 +142,19 @@ class ServerpodAuthGateway(final Client _client) implements AuthGateway {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GitHub's authorization in the browser, or null when the person closed
|
@override
|
||||||
/// GitHub's page without authorizing.
|
bool get canCancelGitHub => _loopback != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelGitHub() async {
|
||||||
|
_gitHubCancelled = true;
|
||||||
|
await _loopback?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GitHub's authorization in the browser, or null when the person
|
||||||
|
/// cancelled it.
|
||||||
Future<GitHubSignInResult?> _authorizeWithGitHub() async {
|
Future<GitHubSignInResult?> _authorizeWithGitHub() async {
|
||||||
|
_gitHubCancelled = false;
|
||||||
final clientId = await gitHubClientId();
|
final clientId = await gitHubClientId();
|
||||||
if (clientId == null) {
|
if (clientId == null) {
|
||||||
throw StateError('This instance offers no GitHub sign-in.');
|
throw StateError('This instance offers no GitHub sign-in.');
|
||||||
|
|
@ -145,10 +168,25 @@ class ServerpodAuthGateway(final Client _client) implements AuthGateway {
|
||||||
// (RFC 8252) asks.
|
// (RFC 8252) asks.
|
||||||
useWebview: false,
|
useWebview: false,
|
||||||
);
|
);
|
||||||
|
if (_loopback case final loopback?) {
|
||||||
|
// Here, after the package has put its own in place at startup.
|
||||||
|
FlutterWebAuth2Platform.instance = loopback;
|
||||||
|
}
|
||||||
|
if (_gitHubCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await GitHubSignInService.instance.signIn();
|
return await GitHubSignInService.instance.signIn();
|
||||||
} on OAuth2PkceUserCancelledException {
|
} on OAuth2PkceUserCancelledException {
|
||||||
return null;
|
return null;
|
||||||
|
} on OAuth2PkceException {
|
||||||
|
// Serverpod (checked with v4.0.3) reports a cancelled browser round
|
||||||
|
// trip as an unknown error, so a cancel is known by having asked for
|
||||||
|
// it.
|
||||||
|
if (_gitHubCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,32 @@ class const _GitHubSignIn() extends StatelessWidget {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
failed: failed,
|
failed: failed,
|
||||||
),
|
),
|
||||||
|
GitHubSignInStateBusy(cancellable: true) => _waiting(context),
|
||||||
GitHubSignInStateBusy() ||
|
GitHubSignInStateBusy() ||
|
||||||
GitHubSignInStateConfirmSwitch() => _button(context, enabled: false),
|
GitHubSignInStateConfirmSwitch() => _button(context, enabled: false),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// While GitHub's page is open in the browser, where only the app can stop
|
||||||
|
/// waiting for it.
|
||||||
|
Widget _waiting(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Continue in your browser.'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => context.read<GitHubSignInBloc>().add(
|
||||||
|
const GitHubSignInEventCancelled(),
|
||||||
|
),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Widget _button(
|
Widget _button(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required bool enabled,
|
required bool enabled,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_bloc: ^9.1.1
|
flutter_bloc: ^9.1.1
|
||||||
|
# The app's own browser half of GitHub sign-in on Linux and Windows
|
||||||
|
# (LoopbackWebAuth) plugs in here, and opens the browser and brings the window
|
||||||
|
# back with the same packages flutter_web_auth_2 uses.
|
||||||
|
flutter_web_auth_2_platform_interface: ^5.0.0
|
||||||
fractional_indexing_dart: ^1.0.7
|
fractional_indexing_dart: ^1.0.7
|
||||||
# Pinned exactly: every device on one app version must compute the same
|
# Pinned exactly: every device on one app version must compute the same
|
||||||
# schedules.
|
# schedules.
|
||||||
|
|
@ -48,6 +52,8 @@ dependencies:
|
||||||
serverpod_auth_idp_flutter: 4.0.3
|
serverpod_auth_idp_flutter: 4.0.3
|
||||||
serverpod_flutter: 4.0.3
|
serverpod_flutter: 4.0.3
|
||||||
serverpod_offline_sync_client: 0.0.8
|
serverpod_offline_sync_client: 0.0.8
|
||||||
|
url_launcher: ^6.3.2
|
||||||
|
window_to_front: ^0.0.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
fake_async: ^1.3.3
|
fake_async: ^1.3.3
|
||||||
|
|
@ -56,6 +62,8 @@ dev_dependencies:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
# A test drives sign-in through its front, as Serverpod's GitHub sign-in does.
|
||||||
|
flutter_web_auth_2: ^5.1.0
|
||||||
# The sync tests (test/sync/) sign in like the app and read the server's database.
|
# The sync tests (test/sync/) sign in like the app and read the server's database.
|
||||||
postgres: ^3.5.16
|
postgres: ^3.5.16
|
||||||
serverpod_auth_core_client: 4.0.3
|
serverpod_auth_core_client: 4.0.3
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,76 @@ void main() {
|
||||||
expect(gateway.status, anonymous);
|
expect(gateway.status, anonymous);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"the person can cancel while GitHub's page is open; nothing changes",
|
||||||
|
() async {
|
||||||
|
gateway
|
||||||
|
..canCancelGitHub = true
|
||||||
|
..gitHubPageOpens = true;
|
||||||
|
final bloc = await ready();
|
||||||
|
|
||||||
|
bloc.add(const GitHubSignInEventRequested());
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(const GitHubSignInStateBusy(cancellable: true)),
|
||||||
|
);
|
||||||
|
bloc.add(const GitHubSignInEventCancelled());
|
||||||
|
|
||||||
|
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||||
|
expect(gateway.gitHubCancels, 1);
|
||||||
|
expect(gateway.status, anonymous);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"offers no cancel where the system's sign-in screen has its own",
|
||||||
|
() async {
|
||||||
|
gateway.gitHubPageOpens = true;
|
||||||
|
final bloc = await ready();
|
||||||
|
|
||||||
|
bloc.add(const GitHubSignInEventRequested());
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(
|
||||||
|
isA<GitHubSignInStateBusy>().having(
|
||||||
|
(state) => state.cancellable,
|
||||||
|
'cancellable',
|
||||||
|
isFalse,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"closing the dialog while GitHub's page is open cancels the sign-in",
|
||||||
|
() async {
|
||||||
|
gateway
|
||||||
|
..canCancelGitHub = true
|
||||||
|
..gitHubPageOpens = true;
|
||||||
|
final bloc = await ready();
|
||||||
|
bloc.add(const GitHubSignInEventRequested());
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(const GitHubSignInStateBusy(cancellable: true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await bloc.close();
|
||||||
|
|
||||||
|
expect(gateway.gitHubCancels, 1);
|
||||||
|
expect(gateway.status, anonymous);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('closing the dialog otherwise cancels nothing', () async {
|
||||||
|
final bloc = await ready();
|
||||||
|
|
||||||
|
await bloc.close();
|
||||||
|
|
||||||
|
expect(gateway.gitHubCancels, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test("switches right away when the GitHub account is someone else's and "
|
test("switches right away when the GitHub account is someone else's and "
|
||||||
'this device holds nothing', () async {
|
'this device holds nothing', () async {
|
||||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||||
|
|
@ -117,6 +187,23 @@ void main() {
|
||||||
|
|
||||||
expect(gateway.gitHubSwitches, 1);
|
expect(gateway.gitHubSwitches, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cancelling GitHub's page for the switch stays anonymous", () async {
|
||||||
|
gateway
|
||||||
|
..canCancelGitHub = true
|
||||||
|
..gitHubPageOpens = true;
|
||||||
|
|
||||||
|
bloc.add(const GitHubSignInEventSwitchConfirmed());
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(const GitHubSignInStateBusy(cancellable: true)),
|
||||||
|
);
|
||||||
|
bloc.add(const GitHubSignInEventCancelled());
|
||||||
|
|
||||||
|
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||||
|
expect(gateway.gitHubSwitches, 0);
|
||||||
|
expect(gateway.status, anonymous);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('says so when signing in fails', () async {
|
test('says so when signing in fails', () async {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
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_learn_flutter/src/feature/auth/data/loopback_web_auth.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(
|
||||||
|
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()),
|
||||||
|
(_) {},
|
||||||
|
);
|
||||||
|
|
@ -81,6 +81,69 @@ void main() {
|
||||||
expect(find.text('Sign in with GitHub'), findsNothing);
|
expect(find.text('Sign in with GitHub'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group("while GitHub's page is open in the browser", () {
|
||||||
|
setUp(() => auth.gitHubPageOpens = true);
|
||||||
|
|
||||||
|
testWidgets('offers to cancel; cancelling changes nothing', (tester) async {
|
||||||
|
auth.canCancelGitHub = true;
|
||||||
|
await openAccount(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign in with GitHub'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Continue in your browser.'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(auth.gitHubCancels, 1);
|
||||||
|
expect(find.text('Sign in with GitHub'), findsOneWidget);
|
||||||
|
expect(find.text("Couldn't sign in with GitHub."), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.text('Progress is saved on this device. Sign in to keep it.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets("offers no cancel where the system's sign-in screen has its "
|
||||||
|
'own', (tester) async {
|
||||||
|
await openAccount(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign in with GitHub'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Continue in your browser.'), findsNothing);
|
||||||
|
expect(find.text('Cancel'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('signs in once GitHub answers', (tester) async {
|
||||||
|
auth.canCancelGitHub = true;
|
||||||
|
await openAccount(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign in with GitHub'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
auth.answerGitHub();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Your progress is kept with your account.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('Continue in your browser.'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('closing the dialog cancels the sign-in', (tester) async {
|
||||||
|
auth.canCancelGitHub = true;
|
||||||
|
await openAccount(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign in with GitHub'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Close'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(auth.gitHubCancels, 1);
|
||||||
|
expect(find.text('Home'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets("asks before leaving this device's studysets behind; staying "
|
testWidgets("asks before leaving this device's studysets behind; staying "
|
||||||
'keeps them', (tester) async {
|
'keeps them', (tester) async {
|
||||||
auth.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
auth.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome
|
||||||
|
|
||||||
/// An [AuthGateway] without a server: signing in creates people named
|
/// An [AuthGateway] without a server: signing in creates people named
|
||||||
/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail. GitHub
|
/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail. GitHub
|
||||||
/// sign-in links the signed-in person, or switches to `github-person`.
|
/// sign-in links the signed-in person, or switches to `github-person`; with
|
||||||
|
/// [gitHubPageOpens], it first waits for [answerGitHub] or a cancel.
|
||||||
class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
||||||
implements AuthGateway {
|
implements AuthGateway {
|
||||||
final _changes = StreamController<AuthStatus>.broadcast();
|
final _changes = StreamController<AuthStatus>.broadcast();
|
||||||
|
|
@ -35,6 +36,19 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
||||||
/// How often this device switched to a GitHub person.
|
/// How often this device switched to a GitHub person.
|
||||||
int gitHubSwitches = 0;
|
int gitHubSwitches = 0;
|
||||||
|
|
||||||
|
/// Whether the app itself waits for the browser, as on Linux and Windows.
|
||||||
|
@override
|
||||||
|
bool canCancelGitHub = false;
|
||||||
|
|
||||||
|
/// While set, GitHub sign-ins wait as if GitHub's page were open in the
|
||||||
|
/// browser, until [answerGitHub] or [cancelGitHub].
|
||||||
|
bool gitHubPageOpens = false;
|
||||||
|
|
||||||
|
/// How often a GitHub sign-in in progress was cancelled.
|
||||||
|
int gitHubCancels = 0;
|
||||||
|
|
||||||
|
Completer<bool>? _gitHubPage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AuthStatus get status => _status;
|
AuthStatus get status => _status;
|
||||||
|
|
||||||
|
|
@ -74,6 +88,9 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
||||||
if (gitHubError case final error?) {
|
if (gitHubError case final error?) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (!await _authorize()) {
|
||||||
|
return GitHubLinkOutcome.cancelled;
|
||||||
|
}
|
||||||
if (gitHubLinkOutcome == GitHubLinkOutcome.linked) {
|
if (gitHubLinkOutcome == GitHubLinkOutcome.linked) {
|
||||||
final person = _status as SignedIn;
|
final person = _status as SignedIn;
|
||||||
_set(SignedIn(authUserId: person.authUserId, isIdentified: true));
|
_set(SignedIn(authUserId: person.authUserId, isIdentified: true));
|
||||||
|
|
@ -86,11 +103,31 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
||||||
if (gitHubError case final error?) {
|
if (gitHubError case final error?) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (!await _authorize()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
gitHubSwitches++;
|
gitHubSwitches++;
|
||||||
_set(const SignedIn(authUserId: 'github-person', isIdentified: true));
|
_set(const SignedIn(authUserId: 'github-person', isIdentified: true));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelGitHub() async {
|
||||||
|
gitHubCancels++;
|
||||||
|
_gitHubPage?.complete(false);
|
||||||
|
_gitHubPage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GitHub sends the browser back to the app: the sign-in goes on.
|
||||||
|
void answerGitHub() {
|
||||||
|
_gitHubPage?.complete(true);
|
||||||
|
_gitHubPage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether GitHub's page was answered rather than cancelled.
|
||||||
|
Future<bool> _authorize() =>
|
||||||
|
gitHubPageOpens ? (_gitHubPage = Completer()).future : Future.value(true);
|
||||||
|
|
||||||
void _set(AuthStatus status) {
|
void _set(AuthStatus status) {
|
||||||
_status = status;
|
_status = status;
|
||||||
_changes.add(status);
|
_changes.add(status);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue