halkyon/halkyon_learn_flutter/test/support/fake_auth_gateway.dart
Tizian 「ティツィアーン」 4ab78d5547
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>
2026-09-30 20:38:20 +02:00

135 lines
3.7 KiB
Dart

import 'dart:async';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.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';
/// An [AuthGateway] without a server: signing in creates people named
/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail. GitHub
/// 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()})
implements AuthGateway {
final _changes = StreamController<AuthStatus>.broadcast();
/// Thrown by the next sign-ins while set.
Exception? signInError;
/// How often signing in was attempted.
int signIns = 0;
/// How often signing out was attempted.
int signOuts = 0;
/// How often the signed-in person's account was deleted.
int accountDeletions = 0;
/// Whether the instance offers GitHub sign-in.
bool offersGitHub = true;
/// How the next GitHub links end.
GitHubLinkOutcome gitHubLinkOutcome = GitHubLinkOutcome.linked;
/// Thrown by the next GitHub sign-ins while set.
Exception? gitHubError;
/// How often this device switched to a GitHub person.
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
AuthStatus get status => _status;
@override
Stream<AuthStatus> get statusChanges => _changes.stream;
@override
Future<void> signInAnonymously() async {
signIns++;
if (signInError case final error?) {
throw error;
}
_set(SignedIn(authUserId: 'anonymous-$signIns', isIdentified: false));
}
@override
Future<void> signOut() async {
signOuts++;
_set(const SignedOut());
}
@override
Future<void> deleteAccount() async {
accountDeletions++;
_set(const SignedOut());
}
/// Ends the session as when the server revokes this device's session.
void revokeSession() => _set(const SignedOut());
@override
Future<String?> gitHubClientId() async =>
offersGitHub ? 'github-client-id' : null;
@override
Future<GitHubLinkOutcome> linkGitHub() async {
if (gitHubError case final error?) {
throw error;
}
if (!await _authorize()) {
return GitHubLinkOutcome.cancelled;
}
if (gitHubLinkOutcome == GitHubLinkOutcome.linked) {
final person = _status as SignedIn;
_set(SignedIn(authUserId: person.authUserId, isIdentified: true));
}
return gitHubLinkOutcome;
}
@override
Future<bool> switchToGitHub() async {
if (gitHubError case final error?) {
throw error;
}
if (!await _authorize()) {
return false;
}
gitHubSwitches++;
_set(const SignedIn(authUserId: 'github-person', isIdentified: 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) {
_status = status;
_changes.add(status);
}
}