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); + }); + }); +}