mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +01:00
crdt_sync_flutter 0.4.0: AccountManager sign-in
Some checks are pending
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Android now uses google_account_token (no googleid or play-services-auth). Desktop PKCE sends client_secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
This commit is contained in:
parent
86b2621cd4
commit
0f332c9057
@ -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 |
|
||||
|
||||
|
||||
@ -84,9 +84,9 @@ Future<FirebaseRestClient?> 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<FirebaseRestClient?> _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);
|
||||
}
|
||||
|
||||
@ -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<String?> googleAnyIdToken({
|
||||
required String serverClientId,
|
||||
required String desktopClientId,
|
||||
String desktopClientSecret = '',
|
||||
}) async {
|
||||
switch (googleFlowFor(
|
||||
serverClientId: serverClientId,
|
||||
@ -87,7 +91,10 @@ Future<String?> 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;
|
||||
|
||||
@ -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<bool> _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<String?> googleDesktopIdToken({
|
||||
required String clientId,
|
||||
String clientSecret = '',
|
||||
ServerBinder binder = _bindLoopback,
|
||||
BrowserLauncher launcher = _launchInBrowser,
|
||||
http.Client? httpClient,
|
||||
@ -83,11 +87,7 @@ Future<String?> 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<String?> googleDesktopIdToken({
|
||||
return await _exchange(
|
||||
client: client,
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
code: code,
|
||||
port: port,
|
||||
verifier: pair.verifier,
|
||||
@ -136,6 +137,7 @@ Future<String?> _awaitRedirect(
|
||||
Future<String?> _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<String?> _exchange({
|
||||
Uri.parse(kGoogleTokenEndpoint),
|
||||
body: tokenExchangeBody(
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
code: code,
|
||||
redirectPort: port,
|
||||
verifier: verifier,
|
||||
|
||||
@ -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<String, String> tokenExchangeBody({
|
||||
required String clientId,
|
||||
required String code,
|
||||
required int redirectPort,
|
||||
required String verifier,
|
||||
String clientSecret = '',
|
||||
}) => <String, String>{
|
||||
'client_id': clientId,
|
||||
if (clientSecret.isNotEmpty) 'client_secret': clientSecret,
|
||||
'code': code,
|
||||
'code_verifier': verifier,
|
||||
'grant_type': 'authorization_code',
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<String?> googleIdToken({
|
||||
required String serverClientId,
|
||||
Future<String?> 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);
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<String, String> 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),
|
||||
),
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user