diff --git a/crdt_sync_flutter/README.md b/crdt_sync_flutter/README.md index 54d4808..f202e76 100644 --- a/crdt_sync_flutter/README.md +++ b/crdt_sync_flutter/README.md @@ -8,7 +8,7 @@ bootstrap, so a new app reaches "syncing" without writing any of it. `crdt_sync` is pure Dart on purpose — it runs from command-line tools and from `wake_alarm`'s headless systemd job, so it cannot depend on -`flutter_secure_storage` or `google_sign_in`. It takes closures instead. +`flutter_secure_storage` or the Android Google plugin. It takes closures instead. Every Flutter app then wrote the same ~390 lines to supply those closures (`firebase_backend.dart` + `google_sign_in_backend.dart` + `google_platform*`), @@ -110,6 +110,11 @@ const kServerClientId = '845446124781-...apps.googleusercontent.com'; /// to loopback. const kDesktopClientId = '845446124781-...apps.googleusercontent.com'; +/// That Desktop client's secret. Google rejects the token exchange without +/// it, but treats it as non-confidential for installed apps: inject it at +/// build time (`--dart-define`), never commit it. '' sends none. +const kDesktopClientSecret = String.fromEnvironment('DESKTOP_CLIENT_SECRET'); + SyncSettingsScreen( accountLoader: () => loadAccount(), accountSaver: saveAccount, @@ -121,6 +126,7 @@ SyncSettingsScreen( tokenFetcher: () => googleAnyIdToken( serverClientId: kServerClientId, desktopClientId: kDesktopClientId, + desktopClientSecret: kDesktopClientSecret, ), ), googleAvailable: googleAnySignInSupported( @@ -132,13 +138,13 @@ SyncSettingsScreen( ### 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: +[`google_account_token`](../google_account_token) wraps Android's AccountManager +(no Google client library in the APK), so a real GTK desktop build cannot use +it. This package covers both halves and picks between them: | Platform | Flow | Entry point | |---|---|---| -| Android / iOS | `google_sign_in` plugin, OS account picker | `googleIdToken` | +| Android | `google_account_token` plugin, OS account chooser | `googleIdToken` | | Linux / Windows / macOS | OAuth **loopback** + PKCE, system browser | `googleDesktopIdToken` | | Web | none (GIS renders its own button) | falls back to password | diff --git a/crdt_sync_flutter/lib/src/bootstrap.dart b/crdt_sync_flutter/lib/src/bootstrap.dart index cc9da37..0c44f5e 100644 --- a/crdt_sync_flutter/lib/src/bootstrap.dart +++ b/crdt_sync_flutter/lib/src/bootstrap.dart @@ -84,9 +84,9 @@ Future openSync( /// Signs in with Google alone, for a device with no account stored yet. /// /// The one-tap path: [openSync] needs an account to know which email to use, -/// but a fresh install has none. [tokenFetcher] is the app's `google_sign_in` -/// call -- a closure because that plugin is Android/iOS/web only and this -/// package must keep working on Linux desktop. +/// but a fresh install has none. [tokenFetcher] is the app's Google token +/// source -- a closure because the Android plugin does not exist elsewhere and +/// this package must keep working on Linux desktop. /// /// Returns null when the user dismisses the picker; throws /// [FirebaseAuthError] when Google succeeds but resolves to the wrong uid, @@ -171,8 +171,5 @@ Future _clientFromStoredSession( store: keystoreCredentialStore(storage: storage), ); if (!await auth.hasSession()) return null; - return FirebaseRestClient( - databaseUrl: app.project.databaseUrl, - auth: auth, - ); + return FirebaseRestClient(databaseUrl: app.project.databaseUrl, auth: auth); } diff --git a/crdt_sync_flutter/lib/src/google_any_sign_in.dart b/crdt_sync_flutter/lib/src/google_any_sign_in.dart index 28b7fae..f6f593d 100644 --- a/crdt_sync_flutter/lib/src/google_any_sign_in.dart +++ b/crdt_sync_flutter/lib/src/google_any_sign_in.dart @@ -1,8 +1,8 @@ /// 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 +/// Two flows exist because one is not enough: the `google_account_token` +/// plugin wraps Android's AccountManager, which has no desktop counterpart, 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. /// @@ -48,7 +48,7 @@ bool desktopSignInSupported(String desktopClientId) => /// 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. + /// The `google_account_token` plugin: Android. plugin, /// OAuth loopback in the system browser: Linux, Windows, macOS. @@ -72,9 +72,13 @@ GoogleFlow googleFlowFor({ /// /// Null when neither is available or the user backs out -- the caller falls /// back to the password path either way. +/// +/// [desktopClientSecret] is the Desktop client's secret (see +/// `googleDesktopIdToken`); '' sends none. Future googleAnyIdToken({ required String serverClientId, required String desktopClientId, + String desktopClientSecret = '', }) async { switch (googleFlowFor( serverClientId: serverClientId, @@ -87,7 +91,10 @@ Future googleAnyIdToken({ case GoogleFlow.plugin: return await googleIdToken(serverClientId: serverClientId); case GoogleFlow.desktop: - return await googleDesktopIdToken(clientId: desktopClientId); + return await googleDesktopIdToken( + clientId: desktopClientId, + clientSecret: desktopClientSecret, + ); // 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 index 57467f1..ab13dcb 100644 --- a/crdt_sync_flutter/lib/src/google_desktop_sign_in.dart +++ b/crdt_sync_flutter/lib/src/google_desktop_sign_in.dart @@ -1,11 +1,11 @@ /// 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. +/// The Android plugin (`google_account_token`) wraps AccountManager, which +/// does not exist on Linux, so a real GTK build cannot use it 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. @@ -66,8 +66,12 @@ Future _launchInBrowser(Uri url) => /// [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. +/// +/// [clientSecret] is that client's secret; Google's token endpoint rejects a +/// Desktop exchange without it. See `tokenExchangeBody`. Future googleDesktopIdToken({ required String clientId, + String clientSecret = '', ServerBinder binder = _bindLoopback, BrowserLauncher launcher = _launchInBrowser, http.Client? httpClient, @@ -83,11 +87,7 @@ Future googleDesktopIdToken({ final client = httpClient ?? http.Client(); try { final opened = await launcher( - buildAuthUrl( - clientId: clientId, - redirectPort: port, - pkce: pair, - ), + 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. @@ -99,6 +99,7 @@ Future googleDesktopIdToken({ return await _exchange( client: client, clientId: clientId, + clientSecret: clientSecret, code: code, port: port, verifier: pair.verifier, @@ -136,6 +137,7 @@ Future _awaitRedirect( Future _exchange({ required http.Client client, required String clientId, + required String clientSecret, required String code, required int port, required String verifier, @@ -144,6 +146,7 @@ Future _exchange({ Uri.parse(kGoogleTokenEndpoint), body: tokenExchangeBody( clientId: clientId, + clientSecret: clientSecret, code: code, redirectPort: port, verifier: verifier, diff --git a/crdt_sync_flutter/lib/src/google_pkce.dart b/crdt_sync_flutter/lib/src/google_pkce.dart index 49ad4ff..db890c5 100644 --- a/crdt_sync_flutter/lib/src/google_pkce.dart +++ b/crdt_sync_flutter/lib/src/google_pkce.dart @@ -136,13 +136,21 @@ RedirectResult parseRedirect(Uri uri, {required String expectedState}) { } /// Form body for exchanging [code] at [kGoogleTokenEndpoint]. +/// +/// [clientSecret] is the Desktop client's secret. Google issues Desktop +/// clients one and rejects the exchange without it even with PKCE, but +/// documents it as non-confidential for installed apps: it ships in the +/// binary, injected at build time and never committed. Left out of the body +/// when empty, so a caller that has none behaves exactly as before. Map tokenExchangeBody({ required String clientId, required String code, required int redirectPort, required String verifier, + String clientSecret = '', }) => { 'client_id': clientId, + if (clientSecret.isNotEmpty) 'client_secret': clientSecret, 'code': code, 'code_verifier': verifier, 'grant_type': 'authorization_code', diff --git a/crdt_sync_flutter/lib/src/google_platform.dart b/crdt_sync_flutter/lib/src/google_platform.dart index f253b2c..39b418a 100644 --- a/crdt_sync_flutter/lib/src/google_platform.dart +++ b/crdt_sync_flutter/lib/src/google_platform.dart @@ -1,10 +1,8 @@ /// 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()`. +/// Conditional export because the answer differs per platform: the io half +/// defers to the `google_account_token` plugin (Android only), the web half is +/// a constant false. library; export 'google_platform_io.dart' diff --git a/crdt_sync_flutter/lib/src/google_platform_io.dart b/crdt_sync_flutter/lib/src/google_platform_io.dart index ca9c0bb..a88ce03 100644 --- a/crdt_sync_flutter/lib/src/google_platform_io.dart +++ b/crdt_sync_flutter/lib/src/google_platform_io.dart @@ -1,15 +1,18 @@ /// 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. +/// The io half. The flow is the `google_account_token` plugin, which wraps the +/// framework AccountManager and therefore exists on Android only. Every other +/// `dart:io` platform (Linux, Windows, macOS) reaches Google through the +/// loopback flow instead. library; -import 'dart:io'; +import 'package:google_account_token/google_account_token.dart'; -/// True on the platforms shipping the programmatic plugin flow. -bool get googleSignInSupported => Platform.isAndroid || Platform.isIOS; +/// True where the one-tap AccountManager flow exists: Android. +/// +/// Asks the plugin rather than `Platform.isAndroid` so the two can never +/// disagree about where a token can be produced. +bool get googleSignInSupported => googleAccountTokenSupported; /// Whether the loopback sign-in flow can run: it needs a bindable socket and /// a system browser, which every `dart:io` platform has. diff --git a/crdt_sync_flutter/lib/src/google_sign_in.dart b/crdt_sync_flutter/lib/src/google_sign_in.dart index 8c25daf..1b89775 100644 --- a/crdt_sync_flutter/lib/src/google_sign_in.dart +++ b/crdt_sync_flutter/lib/src/google_sign_in.dart @@ -1,8 +1,8 @@ -/// One-tap Google sign-in: the `google_sign_in` plugin call, owned once. +/// One-tap Google sign-in: the `google_account_token` 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 +/// systemd, where the Android plugin does not exist. It only ever receives a /// token string through a closure -- see `signInWithGoogle`, which takes a /// `tokenFetcher`. This file is that closure. /// @@ -11,10 +11,8 @@ /// 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'; +import 'package:google_account_token/google_account_token.dart' as account; /// Whether this build can sign in with Google programmatically. /// @@ -33,56 +31,21 @@ bool googleSignInSupported(String serverClientId) => /// 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. +/// Null rather than throwing when the user dismisses the chooser: cancelling +/// is an ordinary outcome, and the caller falls back to the password path. +/// The plugin logs (level 900) every failure, which matters because a +/// *configuration* failure (unregistered SHA-1, wrong client id) arrives +/// through the same path as "the user changed their mind". /// -/// [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`. +/// [serverClientId] must be the project's **Web** OAuth client id -- the token +/// is minted for that audience, and an Android client id yields one Firebase +/// rejects with `audience mismatch`. /// -/// [signInFn] exists for tests, which cannot reach the plugin's platform -/// channel; when supplied it replaces the plugin entirely. +/// [signInFn] exists for tests; when supplied it replaces the plugin entirely. Future googleIdToken({ required String serverClientId, Future Function()? signInFn, }) async { if (signInFn != null) return await 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 + return await account.googleIdToken(serverClientId: serverClientId); } diff --git a/crdt_sync_flutter/pubspec.lock b/crdt_sync_flutter/pubspec.lock index f4ab7e4..f51ee1d 100644 --- a/crdt_sync_flutter/pubspec.lock +++ b/crdt_sync_flutter/pubspec.lock @@ -177,54 +177,15 @@ packages: description: flutter source: sdk version: "0.0.0" - google_identity_services_web: - dependency: transitive - description: - name: google_identity_services_web - sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" - url: "https://pub.dev" - source: hosted - version: "0.3.3+1" - google_sign_in: + google_account_token: dependency: "direct main" description: - name: google_sign_in - sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" - url: "https://pub.dev" - source: hosted - version: "7.2.0" - google_sign_in_android: - dependency: transitive - description: - name: google_sign_in_android - sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" - url: "https://pub.dev" - source: hosted - version: "7.2.16" - google_sign_in_ios: - dependency: transitive - description: - name: google_sign_in_ios - sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 - url: "https://pub.dev" - source: hosted - version: "6.3.0" - google_sign_in_platform_interface: - dependency: transitive - description: - name: google_sign_in_platform_interface - sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - google_sign_in_web: - dependency: transitive - description: - name: google_sign_in_web - sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 - url: "https://pub.dev" - source: hosted - version: "1.1.3" + path: google_account_token + ref: "google_account_token-v0.1.0" + resolved-ref: db70ba204d124c6eadf9b8808cd3d3b3e70363d1 + url: "https://github.com/kuhyx/utils" + source: git + version: "0.1.0" hooks: dependency: transitive description: diff --git a/crdt_sync_flutter/pubspec.yaml b/crdt_sync_flutter/pubspec.yaml index 5eb7813..03021bd 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.3.5 +version: 0.4.0 environment: sdk: ^3.12.2 @@ -23,10 +23,15 @@ 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 + # The one-tap Google ID token on Android: AccountManager chooser, no Google + # client library in the APK. Android only -- web has no token source and + # desktop reaches Google through the loopback flow in # google_desktop_sign_in.dart instead. See google_platform.dart. - google_sign_in: 7.2.0 + google_account_token: + git: + url: https://github.com/kuhyx/utils + ref: google_account_token-v0.1.0 + path: google_account_token http: 1.6.0 shared_preferences: 2.5.6 # Opens the system browser for the desktop loopback sign-in. diff --git a/crdt_sync_flutter/test/google_any_sign_in_test.dart b/crdt_sync_flutter/test/google_any_sign_in_test.dart index c7a5abd..2f0843d 100644 --- a/crdt_sync_flutter/test/google_any_sign_in_test.dart +++ b/crdt_sync_flutter/test/google_any_sign_in_test.dart @@ -1,11 +1,23 @@ import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:google_account_token/google_account_token.dart' + show kGoogleAccountTokenChannel; 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. + TestWidgetsFlutterBinding.ensureInitialized(); + // `flutter test` reports Android as the default target platform, so the + // desktop configuration this suite exercises is pinned explicitly: the + // plugin half is false and the loopback half is live. The Android routing + // has its own group at the bottom. + setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.linux); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(kGoogleAccountTokenChannel, null); + }); + group('desktopSignInSupported', () { test('is true on this host once a desktop client id exists', () { expect(desktopSignInSupported('desktop-cid'), isTrue); @@ -90,4 +102,35 @@ void main() { ); }); }); + + group('on Android', () { + setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); + + test('the plugin flow wins and the desktop one is off', () { + expect( + googleFlowFor(serverClientId: 'web-cid', desktopClientId: 'd'), + GoogleFlow.plugin, + ); + expect(desktopSignInSupported('d'), isFalse); + expect( + googleAnySignInSupported( + serverClientId: 'web-cid', + desktopClientId: '', + ), + isTrue, + ); + }); + + test('googleAnyIdToken reaches the AccountManager plugin', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + kGoogleAccountTokenChannel, + (_) async => 'native-token', + ); + expect( + await googleAnyIdToken(serverClientId: 'web-cid', desktopClientId: ''), + 'native-token', + ); + }); + }); } diff --git a/crdt_sync_flutter/test/google_desktop_sign_in_test.dart b/crdt_sync_flutter/test/google_desktop_sign_in_test.dart index aaf3332..104dc59 100644 --- a/crdt_sync_flutter/test/google_desktop_sign_in_test.dart +++ b/crdt_sync_flutter/test/google_desktop_sign_in_test.dart @@ -23,9 +23,9 @@ BrowserLauncher _respondingLauncher( final state = url.queryParameters['state']!; unawaited( http.get( - Uri.parse('http://127.0.0.1:$port/').replace( - queryParameters: params(state), - ), + Uri.parse( + 'http://127.0.0.1:$port/', + ).replace(queryParameters: params(state)), ), ); return true; @@ -54,6 +54,22 @@ void main() { expect(sentBody['code_verifier'], 'v'); }); + test('sends the configured client_secret in the exchange', () async { + late Map sentBody; + final client = MockClient((request) async { + sentBody = Uri.splitQueryString(request.body); + return http.Response('{"id_token":"t"}', 200); + }); + await googleDesktopIdToken( + clientId: 'desktop-cid', + clientSecret: 'shh', + binder: _bind, + launcher: _respondingLauncher((state) => {'code': 'c', 'state': state}), + httpClient: client, + ); + expect(sentBody['client_secret'], 'shh'); + }); + test('returns null when the user denies consent', () async { final token = await googleDesktopIdToken( clientId: 'cid', @@ -99,9 +115,7 @@ void main() { final token = await googleDesktopIdToken( clientId: 'cid', binder: _bind, - launcher: _respondingLauncher( - (state) => {'code': 'c', 'state': state}, - ), + launcher: _respondingLauncher((state) => {'code': 'c', 'state': state}), httpClient: MockClient( (_) async => http.Response('{"error":"invalid_grant"}', 400), ), diff --git a/crdt_sync_flutter/test/google_pkce_test.dart b/crdt_sync_flutter/test/google_pkce_test.dart index 091ff86..7a12978 100644 --- a/crdt_sync_flutter/test/google_pkce_test.dart +++ b/crdt_sync_flutter/test/google_pkce_test.dart @@ -142,6 +142,29 @@ void main() { expect(body['redirect_uri'], 'http://127.0.0.1:9'); }); + test('tokenExchangeBody omits client_secret when none is configured', () { + final body = tokenExchangeBody( + clientId: 'cid', + code: 'c', + redirectPort: 9, + verifier: 'v', + ); + expect(body.containsKey('client_secret'), isFalse); + }); + + test('tokenExchangeBody sends the Desktop client_secret when given', () { + // Google issues Desktop clients a secret and rejects the exchange + // without it, even with PKCE. + final body = tokenExchangeBody( + clientId: 'cid', + clientSecret: 'the-secret', + code: 'c', + redirectPort: 9, + verifier: 'v', + ); + expect(body['client_secret'], 'the-secret'); + }); + group('idTokenFromResponse', () { test('reads the id_token', () { expect(idTokenFromResponse('{"id_token":"tok"}'), 'tok'); diff --git a/crdt_sync_flutter/test/google_sign_in_test.dart b/crdt_sync_flutter/test/google_sign_in_test.dart index 3fc2825..643a058 100644 --- a/crdt_sync_flutter/test/google_sign_in_test.dart +++ b/crdt_sync_flutter/test/google_sign_in_test.dart @@ -1,18 +1,39 @@ import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:google_account_token/google_account_token.dart' + show kGoogleAccountTokenChannel; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, null); + }); + group('googleSignInSupported', () { + test('is true on Android once a client id is present', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(googleSignInSupported('some-client-id'), isTrue); + }); + + test('is false on iOS: AccountManager is Android-only', () { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + expect(googleSignInSupported('some-client-id'), isFalse); + }); + 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. + test('is false on a desktop host even with a client id', () { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; expect(googleSignInSupported('some-client-id'), isFalse); }); }); @@ -40,4 +61,30 @@ void main() { expect(await googleIdToken(serverClientId: ''), isNull); }); }); + + group('googleIdToken through the AccountManager plugin', () { + setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); + + test('asks the native side for a token for the web client', () async { + MethodCall? seen; + messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, ( + call, + ) async { + seen = call; + return 'native-token'; + }); + final token = await googleIdToken(serverClientId: 'web-cid'); + expect(token, 'native-token'); + expect(seen?.method, 'idToken'); + expect(seen?.arguments, {'serverClientId': 'web-cid'}); + }); + + test('a failing native side is null, not a throw', () async { + messenger.setMockMethodCallHandler( + kGoogleAccountTokenChannel, + (_) async => throw PlatformException(code: 'auth_failed'), + ); + expect(await googleIdToken(serverClientId: 'web-cid'), isNull); + }); + }); }