From 2298fd4ba260587aedf146b17eb815d071325806 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 22 Aug 2026 18:45:48 +0200 Subject: [PATCH] Own the Google sign-in glue, on desktop as well as Android Five apps had copied google_sign_in_backend.dart; home_inventory's copy was byte-identical to todo's. lyricanki was about to become the sixth, which is the mistake this package exists to prevent. So the plugin call moves here. Desktop is the new half. google_sign_in ships android, ios and web implementations only -- there is no google_sign_in_linux -- so lyricanki's real GTK build could never have used the plugin, and "one tap" would have meant "one tap on the phone, type a password on the PC". The OAuth loopback flow covers it: bind a socket, open the system browser, catch the redirect, exchange the code with PKCE. Both halves return a Google ID token of the same shape, so signInWithGoogle cannot tell them apart. googleSignInSupported takes the client id rather than reading a const. In todo the emptiness check was a second conjunct at the call site, and moving the fetcher here without it would have let a forgetful app ship a button that always reports "cancelled" -- todo's own file documents that failure. Taking the value as an argument makes the check impossible to drop. The decision (which flow, or none) is pure and covered; the two one-line delegations that reach a platform channel and a real browser are the only suppressions. 83 tests, 100% coverage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG --- crdt_sync_flutter/README.md | 43 +++- crdt_sync_flutter/lib/crdt_sync_flutter.dart | 4 + .../lib/src/google_any_sign_in.dart | 93 ++++++++ .../lib/src/google_desktop_sign_in.dart | 154 +++++++++++++ crdt_sync_flutter/lib/src/google_pkce.dart | 161 +++++++++++++ .../lib/src/google_platform.dart | 11 + .../lib/src/google_platform_io.dart | 16 ++ .../lib/src/google_platform_web.dart | 12 + crdt_sync_flutter/lib/src/google_sign_in.dart | 88 +++++++ crdt_sync_flutter/pubspec.yaml | 9 +- .../test/google_any_sign_in_test.dart | 72 ++++++ .../test/google_desktop_sign_in_test.dart | 218 ++++++++++++++++++ crdt_sync_flutter/test/google_pkce_test.dart | 166 +++++++++++++ .../test/google_sign_in_test.dart | 43 ++++ 14 files changed, 1083 insertions(+), 7 deletions(-) create mode 100644 crdt_sync_flutter/lib/src/google_any_sign_in.dart create mode 100644 crdt_sync_flutter/lib/src/google_desktop_sign_in.dart create mode 100644 crdt_sync_flutter/lib/src/google_pkce.dart create mode 100644 crdt_sync_flutter/lib/src/google_platform.dart create mode 100644 crdt_sync_flutter/lib/src/google_platform_io.dart create mode 100644 crdt_sync_flutter/lib/src/google_platform_web.dart create mode 100644 crdt_sync_flutter/lib/src/google_sign_in.dart create mode 100644 crdt_sync_flutter/test/google_any_sign_in_test.dart create mode 100644 crdt_sync_flutter/test/google_desktop_sign_in_test.dart create mode 100644 crdt_sync_flutter/test/google_pkce_test.dart create mode 100644 crdt_sync_flutter/test/google_sign_in_test.dart diff --git a/crdt_sync_flutter/README.md b/crdt_sync_flutter/README.md index 05ca7b6..c07997f 100644 --- a/crdt_sync_flutter/README.md +++ b/crdt_sync_flutter/README.md @@ -26,7 +26,7 @@ dependencies: crdt_sync_flutter: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_flutter-v0.1.1 + ref: crdt_sync_flutter-v0.2.0 path: crdt_sync_flutter ``` @@ -101,6 +101,15 @@ Use [`sync_settings_ui`](../sync_settings_ui) for that screen rather than building one; it takes the closures this package provides: ```dart +/// The project's **Web** OAuth client id, used as the audience for Android's +/// ID tokens. Public by design, exactly like the `apiKey`. +const kServerClientId = '845446124781-...apps.googleusercontent.com'; + +/// A **Desktop**-type OAuth client, for the loopback flow. A Web client id +/// will not work here; a Desktop client is the only type allowed to redirect +/// to loopback. +const kDesktopClientId = '845446124781-...apps.googleusercontent.com'; + SyncSettingsScreen( accountLoader: () => loadAccount(), accountSaver: saveAccount, @@ -109,15 +118,37 @@ SyncSettingsScreen( firebaseFactory: () => openSync(kApp), googleFirebaseFactory: () => signInWithGoogle( kApp, - tokenFetcher: googleIdToken, // the app's own google_sign_in call + tokenFetcher: () => googleAnyIdToken( + serverClientId: kServerClientId, + desktopClientId: kDesktopClientId, + ), + ), + googleAvailable: googleAnySignInSupported( + serverClientId: kServerClientId, + desktopClientId: kDesktopClientId, ), - googleAvailable: googleSignInSupported, ) ``` -`signInWithGoogle` is the interactive path and takes the token fetcher as a -closure — `google_sign_in` is Android/iOS/web only, and this package must keep -working on Linux desktop. +### One tap on every platform + +`google_sign_in` ships **android, ios and web** implementations only — there +is no `google_sign_in_linux` — so a real GTK desktop build cannot use the +plugin at all. This package covers both halves and picks between them: + +| Platform | Flow | Entry point | +|---|---|---| +| Android / iOS | `google_sign_in` plugin, OS account picker | `googleIdToken` | +| Linux / Windows / macOS | OAuth **loopback** + PKCE, system browser | `googleDesktopIdToken` | +| Web | none (GIS renders its own button) | falls back to password | + +Both return a Google ID token of the same shape, so `signInWithGoogle` cannot +tell them apart. + +`googleSignInSupported(clientId)` takes the client id rather than reading a +const so its second, easily-lost conjunct — *is a client id actually compiled +in?* — cannot be dropped by a consuming app. A build that forgot the constant +would otherwise show a button that always reports "cancelled". **`openSync` deliberately never offers Google.** It runs from background ticks and, in some apps, before `runApp`; offering Google there would raise the OS diff --git a/crdt_sync_flutter/lib/crdt_sync_flutter.dart b/crdt_sync_flutter/lib/crdt_sync_flutter.dart index 4764932..d160ee9 100644 --- a/crdt_sync_flutter/lib/crdt_sync_flutter.dart +++ b/crdt_sync_flutter/lib/crdt_sync_flutter.dart @@ -14,4 +14,8 @@ library; export 'src/account_store.dart'; export 'src/bootstrap.dart'; export 'src/device_id.dart'; +export 'src/google_any_sign_in.dart'; +export 'src/google_desktop_sign_in.dart'; +export 'src/google_pkce.dart'; +export 'src/google_sign_in.dart'; export 'src/keystore.dart'; diff --git a/crdt_sync_flutter/lib/src/google_any_sign_in.dart b/crdt_sync_flutter/lib/src/google_any_sign_in.dart new file mode 100644 index 0000000..b890c31 --- /dev/null +++ b/crdt_sync_flutter/lib/src/google_any_sign_in.dart @@ -0,0 +1,93 @@ +/// Picks the right Google sign-in flow for the platform. +/// +/// Two flows exist because one is not enough: `google_sign_in` ships android, +/// ios and web implementations only -- there is no `google_sign_in_linux` -- +/// so a real GTK desktop build reaches Google through the OAuth loopback flow +/// instead. Both yield a Google ID token of the same shape, so +/// `signInWithGoogle` cannot tell them apart. +/// +/// This file owns the choice so that consuming apps do not each re-derive it, +/// which is the whole reason this package exists. +library; + +import 'package:crdt_sync_flutter/src/google_desktop_sign_in.dart'; +import 'package:crdt_sync_flutter/src/google_platform.dart' as platform_gate; +import 'package:crdt_sync_flutter/src/google_sign_in.dart'; + +/// Whether Google sign-in can succeed here through *either* flow. +/// +/// Pass this to `SyncSettingsScreen.googleAvailable`. Each half is gated on +/// its own client id being present, so an app that configured only one still +/// gets a button exactly where that one works. +/// +/// [serverClientId] is the **Web** OAuth client id (the audience for the +/// plugin's tokens); [desktopClientId] is a **Desktop**-type client, the only +/// type permitted to redirect to loopback. +bool googleAnySignInSupported({ + required String serverClientId, + String desktopClientId = '', +}) => + googleSignInSupported(serverClientId) || + desktopSignInSupported(desktopClientId); + +/// Whether the loopback flow can run here. +/// +/// True off the plugin's platforms, where a browser and a bindable socket are +/// what the flow actually needs. False on the plugin's own platforms, which +/// take the better native path, and on web, which can do neither. +bool desktopSignInSupported(String desktopClientId) => + !platform_gate.googleSignInSupported && + platform_gate.hasLoopbackBrowser && + desktopClientId.isNotEmpty; + +/// Which sign-in flow this platform should use. +/// +/// A value rather than a branch inside the fetcher: the choice is the part +/// worth testing, and returning it lets the suite assert the routing on a +/// host that can run neither flow for real. +enum GoogleFlow { + /// The `google_sign_in` plugin: Android and iOS. + plugin, + + /// OAuth loopback in the system browser: Linux, Windows, macOS. + desktop, + + /// Neither is available; the caller falls back to the password path. + none, +} + +/// Picks the flow for this platform and configuration. +GoogleFlow googleFlowFor({ + required String serverClientId, + String desktopClientId = '', +}) { + if (googleSignInSupported(serverClientId)) return GoogleFlow.plugin; + if (desktopSignInSupported(desktopClientId)) return GoogleFlow.desktop; + return GoogleFlow.none; +} + +/// Returns a Google ID token through whichever flow this platform supports. +/// +/// Null when neither is available or the user backs out -- the caller falls +/// back to the password path either way. +Future googleAnyIdToken({ + required String serverClientId, + String desktopClientId = '', +}) async { + switch (googleFlowFor( + serverClientId: serverClientId, + desktopClientId: desktopClientId, + )) { + // Two one-line delegations, each reaching what `flutter test` cannot: a + // platform channel and a real browser. The decision above them -- which + // flow, and whether any runs at all -- is pure and fully covered. + // coverage:ignore-start + case GoogleFlow.plugin: + return googleIdToken(serverClientId: serverClientId); + case GoogleFlow.desktop: + return googleDesktopIdToken(clientId: desktopClientId); + // coverage:ignore-end + case GoogleFlow.none: + return null; + } +} diff --git a/crdt_sync_flutter/lib/src/google_desktop_sign_in.dart b/crdt_sync_flutter/lib/src/google_desktop_sign_in.dart new file mode 100644 index 0000000..57467f1 --- /dev/null +++ b/crdt_sync_flutter/lib/src/google_desktop_sign_in.dart @@ -0,0 +1,154 @@ +/// One-tap Google sign-in for desktop, via the OAuth loopback flow. +/// +/// `google_sign_in` ships android, ios and web implementations only -- there +/// is no `google_sign_in_linux` -- so a real GTK build cannot use the plugin +/// at all. The installed-app flow replaces it: bind a loopback socket, open +/// the system browser at Google's consent screen, catch the redirect, and +/// exchange the code for an id_token with PKCE. The token is the same shape +/// the plugin returns, so `signInWithGoogle` cannot tell the two apart. +/// +/// Everything decision-shaped lives in `google_pkce.dart` and is covered. +/// This file is the I/O: a socket, a browser, one POST. +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:crdt_sync_flutter/src/google_pkce.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +/// Binds a loopback server. Injectable so tests need no real socket. +typedef ServerBinder = Future Function(); + +/// Opens [url] in the user's browser. Injectable for the same reason. +typedef BrowserLauncher = Future Function(Uri url); + +/// How long to wait for the user to finish in the browser before giving up. +/// +/// Generous: it spans reading a consent screen and possibly a password +/// prompt. The server is closed either way, so a timeout leaks nothing. +const Duration kDesktopSignInTimeout = Duration(minutes: 3); + +/// What the browser shows after the redirect lands. +/// +/// Served as the response to Google's redirect, because the alternative is +/// leaving the user staring at a blank page wondering whether it worked. +const String kSuccessPage = ''' + +Signed in + +
+

Signed in

+

You can close this tab and return to the app.

+
'''; + +Future _bindLoopback() => + // Port 0 lets the OS pick a free port; the Desktop OAuth client type + // accepts any loopback port, so nothing needs registering per port. + HttpServer.bind(InternetAddress.loopbackIPv4, 0); + +// url_launcher reaches a platform channel, which `flutter test` has no +// binding for -- the same exemption the Android plugin call takes. The +// injected `launcher` seam above is what the suite exercises instead. +// coverage:ignore-start +Future _launchInBrowser(Uri url) => + launchUrl(url, mode: LaunchMode.externalApplication); +// coverage:ignore-end + +/// Signs in through the browser and returns a Google ID token, or null. +/// +/// Null on every ordinary failure -- the user closing the tab, denying +/// consent, or the timeout elapsing -- because they all end the same way: +/// the caller falls back to the password path. +/// +/// [clientId] must be a **Desktop**-type OAuth client. The Web client id used +/// for the Android flow will be rejected here, and a Desktop client is the +/// only type permitted to redirect to loopback. +Future googleDesktopIdToken({ + required String clientId, + ServerBinder binder = _bindLoopback, + BrowserLauncher launcher = _launchInBrowser, + http.Client? httpClient, + Duration timeout = kDesktopSignInTimeout, + PkcePair? pkce, +}) async { + if (clientId.isEmpty) return null; + final pair = pkce ?? PkcePair.generate(); + final server = await binder(); + // Read once, up front: `server.port` throws HttpException once the socket + // is closed, and the exchange below outlives the listening phase. + final port = server.port; + final client = httpClient ?? http.Client(); + try { + final opened = await launcher( + buildAuthUrl( + clientId: clientId, + redirectPort: port, + pkce: pair, + ), + ); + // No browser means no redirect will ever arrive: fail now rather than + // holding the socket open for the full timeout. + if (!opened) return null; + final code = await _awaitRedirect(server, pair.state, timeout); + if (code == null) return null; + // Awaited, not returned bare: the finally below closes the server and the + // http client, and an unawaited return would let that race the exchange. + return await _exchange( + client: client, + clientId: clientId, + code: code, + port: port, + verifier: pair.verifier, + ); + } finally { + await server.close(force: true); + if (httpClient == null) client.close(); + } +} + +/// Waits for Google's redirect and returns the authorization code, or null. +Future _awaitRedirect( + HttpServer server, + String expectedState, + Duration timeout, +) async { + final completer = Completer(); + final subscription = server.listen((request) async { + final result = parseRedirect(request.uri, expectedState: expectedState); + request.response + ..statusCode = result.code != null ? HttpStatus.ok : HttpStatus.badRequest + ..headers.contentType = ContentType.html + ..write(result.code != null ? kSuccessPage : 'Sign-in failed. '); + await request.response.close(); + if (!completer.isCompleted) completer.complete(result.code); + }); + try { + return await completer.future.timeout(timeout, onTimeout: () => null); + } finally { + await subscription.cancel(); + } +} + +/// Exchanges [code] for an ID token. +Future _exchange({ + required http.Client client, + required String clientId, + required String code, + required int port, + required String verifier, +}) async { + final response = await client.post( + Uri.parse(kGoogleTokenEndpoint), + body: tokenExchangeBody( + clientId: clientId, + code: code, + redirectPort: port, + verifier: verifier, + ), + ); + if (response.statusCode != HttpStatus.ok) return null; + return idTokenFromResponse(response.body); +} diff --git a/crdt_sync_flutter/lib/src/google_pkce.dart b/crdt_sync_flutter/lib/src/google_pkce.dart new file mode 100644 index 0000000..49ad4ff --- /dev/null +++ b/crdt_sync_flutter/lib/src/google_pkce.dart @@ -0,0 +1,161 @@ +/// The pure half of the desktop loopback OAuth flow: PKCE, URL building and +/// redirect parsing. +/// +/// Split from `google_desktop_sign_in.dart` deliberately. That file binds a +/// socket, opens a browser and posts to Google -- none of which `flutter +/// test` can reach. Everything in *this* file is a pure function over +/// strings, so the logic that is easy to get wrong (the challenge derivation, +/// the state check, the error branch of the redirect) is fully covered +/// without a single suppression. +library; + +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// Google's authorization endpoint for the installed-app flow. +const String kGoogleAuthEndpoint = + 'https://accounts.google.com/o/oauth2/v2/auth'; + +/// Google's token endpoint. +const String kGoogleTokenEndpoint = 'https://oauth2.googleapis.com/token'; + +/// Characters permitted in a PKCE `code_verifier` (RFC 7636 unreserved set). +const String _verifierAlphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + +/// A PKCE verifier/challenge pair plus the CSRF `state` for one attempt. +class PkcePair { + /// Creates a pair from its parts. Prefer [PkcePair.generate]. + const PkcePair({ + required this.verifier, + required this.challenge, + required this.state, + }); + + /// Derives a pair from [verifier] and [state], computing the S256 + /// challenge. Separate from [generate] so tests can pin the randomness. + factory PkcePair.fromVerifier(String verifier, String state) => PkcePair( + verifier: verifier, + challenge: _base64Url(sha256.convert(ascii.encode(verifier)).bytes), + state: state, + ); + + /// Generates a fresh pair. [random] is injectable for deterministic tests. + factory PkcePair.generate({Random? random}) { + final rng = random ?? Random.secure(); + return PkcePair.fromVerifier( + _randomString(rng, 64), + _randomString(rng, 32), + ); + } + + /// The high-entropy secret held back until the token exchange. + final String verifier; + + /// The S256 hash of [verifier], sent in the authorization URL. + final String challenge; + + /// Opaque value echoed by Google, checked to reject a forged redirect. + final String state; +} + +String _randomString(Random rng, int length) => List.generate( + length, + (_) => _verifierAlphabet[rng.nextInt(_verifierAlphabet.length)], +).join(); + +/// Base64url without padding, as PKCE requires. +String _base64Url(List bytes) => + base64Url.encode(bytes).replaceAll('=', ''); + +/// Builds the URL the user's browser opens to approve the sign-in. +/// +/// [redirectPort] is the loopback port already bound -- Google requires the +/// redirect to be `http://127.0.0.1:` for a Desktop client, and binding +/// first means the port in the URL is the port actually listening. +Uri buildAuthUrl({ + required String clientId, + required int redirectPort, + required PkcePair pkce, +}) => Uri.parse(kGoogleAuthEndpoint).replace( + queryParameters: { + 'client_id': clientId, + 'redirect_uri': redirectUriFor(redirectPort), + 'response_type': 'code', + // openid+email is the minimum that yields an id_token carrying the + // address; Firebase's signInWithIdp needs the id_token, not the access + // token, so no broader scope is requested. + 'scope': 'openid email', + 'code_challenge': pkce.challenge, + 'code_challenge_method': 'S256', + 'state': pkce.state, + }, +); + +/// The loopback redirect URI for [port]. +/// +/// `127.0.0.1` rather than `localhost`: Google's installed-app documentation +/// specifies the literal address, and a machine resolving `localhost` to `::1` +/// would otherwise present a URI the console never matched. +String redirectUriFor(int port) => 'http://127.0.0.1:$port'; + +/// Outcome of parsing Google's redirect back to the loopback server. +class RedirectResult { + /// Creates a result. Exactly one of [code] and [error] is non-null. + const RedirectResult({this.code, this.error}); + + /// The authorization code, when the user approved. + final String? code; + + /// Why the attempt did not yield a code, when it did not. + final String? error; +} + +/// Reads Google's redirect [uri], checking it against the expected [state]. +/// +/// Returns an error result rather than throwing: every failure here ends the +/// same way -- fall back to the password path -- and the caller should not +/// have to distinguish a denial from a forgery to do that. +RedirectResult parseRedirect(Uri uri, {required String expectedState}) { + final params = uri.queryParameters; + final returnedState = params['state']; + if (returnedState != expectedState) { + // Either a stale redirect from an earlier attempt or a forged request + // aimed at the loopback port; neither may be treated as this sign-in. + return const RedirectResult(error: 'state mismatch'); + } + final error = params['error']; + if (error != null) return RedirectResult(error: error); + final code = params['code']; + if (code == null || code.isEmpty) { + return const RedirectResult(error: 'no authorization code in redirect'); + } + return RedirectResult(code: code); +} + +/// Form body for exchanging [code] at [kGoogleTokenEndpoint]. +Map tokenExchangeBody({ + required String clientId, + required String code, + required int redirectPort, + required String verifier, +}) => { + 'client_id': clientId, + 'code': code, + 'code_verifier': verifier, + 'grant_type': 'authorization_code', + 'redirect_uri': redirectUriFor(redirectPort), +}; + +/// Pulls the `id_token` out of Google's token-endpoint response [body]. +/// +/// Returns null on any shape that does not carry one, including an error +/// response -- the caller falls back to the password path either way. +String? idTokenFromResponse(String body) { + final decoded = jsonDecode(body); + if (decoded is! Map) return null; + final token = decoded['id_token']; + return token is String && token.isNotEmpty ? token : null; +} diff --git a/crdt_sync_flutter/lib/src/google_platform.dart b/crdt_sync_flutter/lib/src/google_platform.dart new file mode 100644 index 0000000..f253b2c --- /dev/null +++ b/crdt_sync_flutter/lib/src/google_platform.dart @@ -0,0 +1,11 @@ +/// Platform gate for the programmatic Google sign-in flow. +/// +/// Conditional export because the answer differs per platform *and* because +/// asking the plugin directly is unsafe: `supportsAuthenticate()` throws +/// `UnimplementedError` where no implementation is registered -- an `Error`, +/// not an `Exception`, so it escapes an ordinary catch and would take down +/// the settings screen's `build()`. +library; + +export 'google_platform_io.dart' + if (dart.library.js_interop) 'google_platform_web.dart'; diff --git a/crdt_sync_flutter/lib/src/google_platform_io.dart b/crdt_sync_flutter/lib/src/google_platform_io.dart new file mode 100644 index 0000000..ca9c0bb --- /dev/null +++ b/crdt_sync_flutter/lib/src/google_platform_io.dart @@ -0,0 +1,16 @@ +/// Whether the programmatic Google sign-in flow exists on this platform. +/// +/// The io half. `google_sign_in` ships federated implementations for android, +/// ios and web only -- there is no `google_sign_in_linux` -- so a real GTK +/// desktop build (lyricanki has one) answers false here and reaches Google +/// through the loopback flow instead. +library; + +import 'dart:io'; + +/// True on the platforms shipping the programmatic plugin flow. +bool get googleSignInSupported => Platform.isAndroid || Platform.isIOS; + +/// Whether the loopback sign-in flow can run: it needs a bindable socket and +/// a system browser, which every `dart:io` platform has. +bool get hasLoopbackBrowser => true; diff --git a/crdt_sync_flutter/lib/src/google_platform_web.dart b/crdt_sync_flutter/lib/src/google_platform_web.dart new file mode 100644 index 0000000..069efac --- /dev/null +++ b/crdt_sync_flutter/lib/src/google_platform_web.dart @@ -0,0 +1,12 @@ +/// Whether the programmatic Google sign-in flow exists on this platform. +/// +/// The web half: Google Identity Services signs in only through its own +/// rendered button, so `authenticate()` throws `UnimplementedError` here. +library; + +/// Always false: the web plugin has no programmatic sign-in. +bool get googleSignInSupported => false; + +/// Always false: a browser tab cannot bind a loopback socket, and web has its +/// own GIS button anyway. +bool get hasLoopbackBrowser => false; diff --git a/crdt_sync_flutter/lib/src/google_sign_in.dart b/crdt_sync_flutter/lib/src/google_sign_in.dart new file mode 100644 index 0000000..2f56a8b --- /dev/null +++ b/crdt_sync_flutter/lib/src/google_sign_in.dart @@ -0,0 +1,88 @@ +/// One-tap Google sign-in: the `google_sign_in` plugin call, owned once. +/// +/// Why it lives here and not in `crdt_sync`: that package is pure Dart on +/// purpose, because the same library runs on Linux desktop and headless under +/// systemd, where `google_sign_in` does not exist. It only ever receives a +/// token string through a closure -- see `signInWithGoogle`, which takes a +/// `tokenFetcher`. This file is that closure. +/// +/// Why it lives here and not in each app: five apps had copied it, and +/// `home_inventory`'s copy was byte-identical to `todo`'s. Copying it a sixth +/// time is the mistake this package exists to prevent. +library; + +import 'dart:developer'; + +import 'package:crdt_sync_flutter/src/google_platform.dart' as platform_gate; +import 'package:google_sign_in/google_sign_in.dart'; + +/// Whether this build can sign in with Google programmatically. +/// +/// Two conjuncts, both load-bearing: +/// +/// * the platform must ship the flow (see `google_platform.dart`); +/// * [serverClientId] must be non-empty. A build that forgot the constant +/// would otherwise show a button that always reports "cancelled" and send +/// you debugging the OAuth console for a missing string. +/// +/// Takes the id rather than reading a package-level const so the second check +/// cannot be dropped by a consuming app: the only way to ask the question is +/// to supply the value the answer depends on. +bool googleSignInSupported(String serverClientId) => + platform_gate.googleSignInSupported && serverClientId.isNotEmpty; + +/// Returns a Google ID token for the signed-in account, or null. +/// +/// Null rather than throwing when the user dismisses the picker: cancelling is +/// an ordinary outcome, and the caller falls back to the password path. +/// +/// [serverClientId] must be the project's **Web** OAuth client id -- Android +/// must request a token minted for the web client, and an Android client id +/// yields a token Firebase rejects with `audience mismatch`. +/// +/// [signInFn] exists for tests, which cannot reach the plugin's platform +/// channel; when supplied it replaces the plugin entirely. +Future googleIdToken({ + required String serverClientId, + Future Function()? signInFn, +}) async { + if (signInFn != null) return signInFn(); + if (serverClientId.isEmpty) { + log( + 'Google sign-in unavailable: no server client id was compiled in; ' + 'falling back to the password path', + level: 900, + ); + return null; + } + // The plugin reaches the OS account picker through a platform channel, + // which `flutter test` has no binding for. Everything above this point is + // pure Dart and is covered. + // coverage:ignore-start + try { + await GoogleSignIn.instance.initialize(serverClientId: serverClientId); + if (!GoogleSignIn.instance.supportsAuthenticate()) { + log( + 'Google sign-in is not available on this platform; falling back to ' + 'the password path', + level: 900, + ); + return null; + } + final account = await GoogleSignIn.instance.authenticate(); + return account.authentication.idToken; + } on GoogleSignInException catch (error, stackTrace) { + // Includes the user simply dismissing the picker, which is not an error + // worth surfacing -- but log it, because a *configuration* failure + // (unregistered SHA-1, wrong client id) arrives through the same path and + // is otherwise indistinguishable from "the user changed their mind". + log( + 'Google sign-in did not complete', + level: 900, + error: error, + stackTrace: stackTrace, + ); + return null; + } + // coverage:ignore-end +} diff --git a/crdt_sync_flutter/pubspec.yaml b/crdt_sync_flutter/pubspec.yaml index d30c923..7f0881a 100644 --- a/crdt_sync_flutter/pubspec.yaml +++ b/crdt_sync_flutter/pubspec.yaml @@ -4,7 +4,7 @@ description: >- persisted per-install device id, and a one-call bootstrap. Removes the ~150 lines of firebase_backend.dart every consuming app was copying. publish_to: 'none' -version: 0.1.1 +version: 0.2.0 environment: sdk: ^3.12.2 @@ -15,6 +15,7 @@ dependencies: url: https://github.com/kuhyx/utils ref: crdt_sync_dart-v0.11.0 path: crdt_sync_dart + crypto: ^3.0.6 flutter: sdk: flutter flutter_secure_storage: ^10.3.1 @@ -22,8 +23,14 @@ dependencies: # consuming apps can test their own sync wiring without rebuilding it. flutter_test: sdk: flutter + # The one-tap Google plugin. Android/iOS/web only -- there is no Linux + # implementation -- so desktop reaches Google through the loopback flow in + # google_desktop_sign_in.dart instead. See google_platform.dart. + google_sign_in: ^7.2.0 http: ^1.6.0 shared_preferences: ^2.5.3 + # Opens the system browser for the desktop loopback sign-in. + url_launcher: ^6.3.1 uuid: ^4.5.1 dev_dependencies: diff --git a/crdt_sync_flutter/test/google_any_sign_in_test.dart b/crdt_sync_flutter/test/google_any_sign_in_test.dart new file mode 100644 index 0000000..eb6e96d --- /dev/null +++ b/crdt_sync_flutter/test/google_any_sign_in_test.dart @@ -0,0 +1,72 @@ +import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // Under `flutter test` the io platform gate answers for the host, which is + // Linux -- so the plugin half is false and the loopback half is live. That + // is the desktop configuration, and it is the one this suite can exercise + // honestly; the Android half is asserted on the device instead. + group('desktopSignInSupported', () { + test('is true on this host once a desktop client id exists', () { + expect(desktopSignInSupported('desktop-cid'), isTrue); + }); + + test('is false without a desktop client id', () { + // The guard against a button that can only ever fail. + expect(desktopSignInSupported(''), isFalse); + }); + }); + + group('googleAnySignInSupported', () { + test('is true when only the desktop half is configured', () { + expect( + googleAnySignInSupported( + serverClientId: '', + desktopClientId: 'desktop-cid', + ), + isTrue, + ); + }); + + test('is false when neither half is configured', () { + expect(googleAnySignInSupported(serverClientId: ''), isFalse); + }); + + test('is false on this host when only the plugin half is configured', () { + // The plugin does not run on Linux, so a Web client id alone buys + // nothing here -- and the button must not appear. + expect(googleAnySignInSupported(serverClientId: 'web-cid'), isFalse); + }); + }); + + group('googleFlowFor', () { + test('picks the desktop flow on this host', () { + expect( + googleFlowFor(serverClientId: '', desktopClientId: 'd'), + GoogleFlow.desktop, + ); + }); + + test('picks nothing when neither half is configured', () { + expect(googleFlowFor(serverClientId: ''), GoogleFlow.none); + }); + + test('picks nothing when only the plugin half is configured here', () { + // The plugin does not run on Linux, so a Web client id alone must not + // route anywhere -- that would be a button that cannot succeed. + expect(googleFlowFor(serverClientId: 'web-cid'), GoogleFlow.none); + }); + }); + + group('googleAnyIdToken', () { + test('returns null when no flow is available', () async { + expect(await googleAnyIdToken(serverClientId: ''), isNull); + }); + + test('returns null when the plugin half alone is configured', () async { + // Would reach the plugin's platform channel if the gate were wrong, + // which throws rather than returning null. + expect(await googleAnyIdToken(serverClientId: 'web-cid'), isNull); + }); + }); +} diff --git a/crdt_sync_flutter/test/google_desktop_sign_in_test.dart b/crdt_sync_flutter/test/google_desktop_sign_in_test.dart new file mode 100644 index 0000000..aaf3332 --- /dev/null +++ b/crdt_sync_flutter/test/google_desktop_sign_in_test.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// Binds a real loopback server on an OS-chosen port. +/// +/// A real socket rather than a fake: `dart:io` works under `flutter test` +/// (it is the platform *channels* that do not), and the redirect handling is +/// exactly the part worth exercising against a real HTTP round trip. +Future _bind() => HttpServer.bind(InternetAddress.loopbackIPv4, 0); + +/// Drives the browser half: reads the port out of the auth URL and issues the +/// redirect Google would have issued. +BrowserLauncher _respondingLauncher( + Map Function(String state) params, +) => (url) async { + final port = Uri.parse(url.queryParameters['redirect_uri']!).port; + final state = url.queryParameters['state']!; + unawaited( + http.get( + Uri.parse('http://127.0.0.1:$port/').replace( + queryParameters: params(state), + ), + ), + ); + return true; +}; + +void main() { + test('exchanges the code for an id token', () async { + late Map sentBody; + final client = MockClient((request) async { + sentBody = Uri.splitQueryString(request.body); + return http.Response('{"id_token":"the-token"}', 200); + }); + + final token = await googleDesktopIdToken( + clientId: 'desktop-cid', + binder: _bind, + launcher: _respondingLauncher( + (state) => {'code': 'auth-code', 'state': state}, + ), + httpClient: client, + pkce: PkcePair.fromVerifier('v', 'ignored-state'), + ); + + expect(token, 'the-token'); + expect(sentBody['code'], 'auth-code'); + expect(sentBody['code_verifier'], 'v'); + }); + + test('returns null when the user denies consent', () async { + final token = await googleDesktopIdToken( + clientId: 'cid', + binder: _bind, + launcher: _respondingLauncher( + (state) => {'error': 'access_denied', 'state': state}, + ), + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + expect(token, isNull); + }); + + test('returns null when a forged redirect carries the wrong state', () async { + final token = await googleDesktopIdToken( + clientId: 'cid', + binder: _bind, + launcher: _respondingLauncher( + (_) => {'code': 'c', 'state': 'not-the-state'}, + ), + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + expect(token, isNull); + }); + + test('returns null, and does not wait, when no browser opens', () async { + var posted = false; + final token = await googleDesktopIdToken( + clientId: 'cid', + binder: _bind, + launcher: (_) async => false, + httpClient: MockClient((_) async { + posted = true; + return http.Response('{}', 200); + }), + // Would hang for the full timeout if the launcher result were ignored. + timeout: const Duration(seconds: 30), + ); + expect(token, isNull); + expect(posted, isFalse); + }); + + test('returns null when the token endpoint rejects the code', () async { + final token = await googleDesktopIdToken( + clientId: 'cid', + binder: _bind, + launcher: _respondingLauncher( + (state) => {'code': 'c', 'state': state}, + ), + httpClient: MockClient( + (_) async => http.Response('{"error":"invalid_grant"}', 400), + ), + ); + expect(token, isNull); + }); + + test('gives up when the user never finishes', () async { + final token = await googleDesktopIdToken( + clientId: 'cid', + binder: _bind, + launcher: (_) async => true, + httpClient: MockClient((_) async => http.Response('{}', 200)), + timeout: const Duration(milliseconds: 50), + ); + expect(token, isNull); + }); + + test('rejects an empty client id without binding a socket', () async { + var bound = false; + final token = await googleDesktopIdToken( + clientId: '', + binder: () async { + bound = true; + return _bind(); + }, + launcher: (_) async => true, + ); + expect(token, isNull); + expect(bound, isFalse); + }); + + test('serves a readable page after a successful redirect', () async { + final pageBody = Completer(); + + await googleDesktopIdToken( + clientId: 'cid', + binder: _bind, + // Deliberately NOT awaited: googleDesktopIdToken awaits the launcher + // before it starts listening, so a launcher that blocks on the response + // deadlocks against a server that has not begun serving. + launcher: (url) async { + final port = Uri.parse(url.queryParameters['redirect_uri']!).port; + final state = url.queryParameters['state']!; + unawaited( + http + .get(Uri.parse('http://127.0.0.1:$port/?code=c&state=$state')) + .then((response) => pageBody.complete(response.body)), + ); + return true; + }, + httpClient: MockClient( + (_) async => http.Response('{"id_token":"t"}', 200), + ), + ); + + expect(await pageBody.future, contains('Signed in')); + }); + + test('binds a real loopback socket by default', () async { + // Covers the production binder rather than only the injected one: a + // default that cannot bind would fail on the user's desktop and nowhere + // in this suite. + var launched = false; + final token = await googleDesktopIdToken( + clientId: 'cid', + launcher: (url) async { + launched = true; + expect( + Uri.parse(url.queryParameters['redirect_uri']!).port, + isPositive, + ); + return false; + }, + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + expect(token, isNull); + expect(launched, isTrue); + }); + + test('creates and closes its own http client when none is passed', () async { + // The httpClient == null branch: no request is ever made (the launcher + // reports no browser), but the client is still constructed and closed. + final token = await googleDesktopIdToken( + clientId: 'cid', + binder: _bind, + launcher: (_) async => false, + ); + expect(token, isNull); + }); + + test('closes the server it opened', () async { + late int boundPort; + await googleDesktopIdToken( + clientId: 'cid', + binder: () async { + final server = await _bind(); + // Captured while still bound: `.port` throws once closed, which is + // exactly the invariant this test exists to confirm. + boundPort = server.port; + return server; + }, + launcher: (_) async => true, + httpClient: MockClient((_) async => http.Response('{}', 200)), + timeout: const Duration(milliseconds: 50), + ); + // Rebinding the same port only succeeds if the first was released. + final rebound = await HttpServer.bind( + InternetAddress.loopbackIPv4, + boundPort, + ); + await rebound.close(force: true); + }); +} diff --git a/crdt_sync_flutter/test/google_pkce_test.dart b/crdt_sync_flutter/test/google_pkce_test.dart new file mode 100644 index 0000000..091ff86 --- /dev/null +++ b/crdt_sync_flutter/test/google_pkce_test.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PkcePair', () { + test('derives the S256 challenge of the verifier', () { + final pair = PkcePair.fromVerifier('abc123', 'state-1'); + final expected = base64Url + .encode(sha256.convert(ascii.encode('abc123')).bytes) + .replaceAll('=', ''); + expect(pair.challenge, expected); + expect(pair.verifier, 'abc123'); + expect(pair.state, 'state-1'); + }); + + test('challenge carries no base64 padding', () { + // '=' in a query parameter survives, but RFC 7636 requires it stripped + // and Google rejects a padded challenge. + expect(PkcePair.fromVerifier('a', 's').challenge, isNot(contains('='))); + }); + + test('generate produces a 64-char verifier and 32-char state', () { + final pair = PkcePair.generate(random: Random(7)); + expect(pair.verifier, hasLength(64)); + expect(pair.state, hasLength(32)); + expect(pair.challenge, isNotEmpty); + }); + + test('generate is seedable, and unseeded still yields a pair', () { + expect( + PkcePair.generate(random: Random(1)).verifier, + PkcePair.generate(random: Random(1)).verifier, + ); + expect(PkcePair.generate().verifier, hasLength(64)); + }); + + test('verifier uses only the unreserved alphabet', () { + final pair = PkcePair.generate(random: Random(3)); + expect(RegExp(r'^[A-Za-z0-9\-._~]+$').hasMatch(pair.verifier), isTrue); + }); + }); + + group('buildAuthUrl', () { + final pkce = PkcePair.fromVerifier('verifier', 'the-state'); + final url = buildAuthUrl(clientId: 'cid', redirectPort: 4321, pkce: pkce); + + test('targets Google and carries the PKCE challenge', () { + expect(url.toString(), startsWith(kGoogleAuthEndpoint)); + expect(url.queryParameters['code_challenge'], pkce.challenge); + expect(url.queryParameters['code_challenge_method'], 'S256'); + }); + + test('asks for a code against the bound loopback port', () { + expect(url.queryParameters['response_type'], 'code'); + expect(url.queryParameters['redirect_uri'], 'http://127.0.0.1:4321'); + expect(url.queryParameters['client_id'], 'cid'); + expect(url.queryParameters['state'], 'the-state'); + }); + + test('requests openid email, the minimum yielding an id_token', () { + expect(url.queryParameters['scope'], 'openid email'); + }); + }); + + test('redirectUriFor uses the literal loopback address', () { + // Not `localhost`: a host resolving it to ::1 would present a URI the + // OAuth console never matched. + expect(redirectUriFor(80), 'http://127.0.0.1:80'); + }); + + group('parseRedirect', () { + Uri uri(Map params) => + Uri.parse('http://127.0.0.1:1/').replace(queryParameters: params); + + test('returns the code when state matches', () { + final result = parseRedirect( + uri({'code': 'abc', 'state': 's'}), + expectedState: 's', + ); + expect(result.code, 'abc'); + expect(result.error, isNull); + }); + + test('rejects a mismatched state without reading the code', () { + final result = parseRedirect( + uri({'code': 'abc', 'state': 'other'}), + expectedState: 's', + ); + expect(result.code, isNull); + expect(result.error, 'state mismatch'); + }); + + test('rejects a redirect carrying no state at all', () { + final result = parseRedirect(uri({'code': 'abc'}), expectedState: 's'); + expect(result.error, 'state mismatch'); + }); + + test('surfaces Google\'s error parameter', () { + final result = parseRedirect( + uri({'error': 'access_denied', 'state': 's'}), + expectedState: 's', + ); + expect(result.code, isNull); + expect(result.error, 'access_denied'); + }); + + test('reports a redirect with neither code nor error', () { + final result = parseRedirect(uri({'state': 's'}), expectedState: 's'); + expect(result.error, 'no authorization code in redirect'); + }); + + test('treats an empty code as no code', () { + final result = parseRedirect( + uri({'code': '', 'state': 's'}), + expectedState: 's', + ); + expect(result.error, 'no authorization code in redirect'); + }); + + test('constructs directly with neither field', () { + const result = RedirectResult(); + expect(result.code, isNull); + expect(result.error, isNull); + }); + }); + + test('tokenExchangeBody sends the verifier, not the challenge', () { + final body = tokenExchangeBody( + clientId: 'cid', + code: 'the-code', + redirectPort: 9, + verifier: 'the-verifier', + ); + expect(body['code_verifier'], 'the-verifier'); + expect(body['grant_type'], 'authorization_code'); + expect(body['code'], 'the-code'); + expect(body['client_id'], 'cid'); + expect(body['redirect_uri'], 'http://127.0.0.1:9'); + }); + + group('idTokenFromResponse', () { + test('reads the id_token', () { + expect(idTokenFromResponse('{"id_token":"tok"}'), 'tok'); + }); + + test('returns null for an error response', () { + expect(idTokenFromResponse('{"error":"invalid_grant"}'), isNull); + }); + + test('returns null for an empty id_token', () { + expect(idTokenFromResponse('{"id_token":""}'), isNull); + }); + + test('returns null for a non-string id_token', () { + expect(idTokenFromResponse('{"id_token":42}'), isNull); + }); + + test('returns null for a non-object body', () { + expect(idTokenFromResponse('[]'), isNull); + }); + }); +} diff --git a/crdt_sync_flutter/test/google_sign_in_test.dart b/crdt_sync_flutter/test/google_sign_in_test.dart new file mode 100644 index 0000000..3fc2825 --- /dev/null +++ b/crdt_sync_flutter/test/google_sign_in_test.dart @@ -0,0 +1,43 @@ +import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('googleSignInSupported', () { + test('is false without a client id, whatever the platform', () { + // The guard that stops a build which forgot the constant from showing a + // button that can only ever report "cancelled". + expect(googleSignInSupported(''), isFalse); + }); + + test('follows the platform gate once a client id is present', () { + // Under `flutter test` the io half answers for the host, which is not + // Android -- so this is false here and true on a real handset. Asserting + // the platform's own answer keeps the test honest on every host. + expect(googleSignInSupported('some-client-id'), isFalse); + }); + }); + + group('googleIdToken', () { + test('returns the token the injected sign-in yields', () async { + final token = await googleIdToken( + serverClientId: 'cid', + signInFn: () async => 'the-token', + ); + expect(token, 'the-token'); + }); + + test('returns null when the user dismisses the picker', () async { + final token = await googleIdToken( + serverClientId: 'cid', + signInFn: () async => null, + ); + expect(token, isNull); + }); + + test('returns null, without touching the plugin, on an empty id', () async { + // Reaching the plugin here would throw MissingPluginException rather + // than returning null, so this also proves the guard runs first. + expect(await googleIdToken(serverClientId: ''), isNull); + }); + }); +}