From feaaf691037da1580fabc992819180d85a97c90a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 22 Aug 2026 17:19:49 +0200 Subject: [PATCH] Accept an empty password when parsing a stored Firebase account Both the Google one-tap path and the seeded-session path deliberately write `FirebaseAccount(email: ..., password: '')` -- on those devices the refresh token in the keystore is the credential, and there is no password to store. `tryParse` rejected exactly that blob. So a device signed in with Google wrote an account marker it could never read back: every later launch saw "not configured", fell through to the mirror, and left a perfectly good refresh token unused in the keystore beside it. Only widens what parses, so no caller that previously got an account back can regress. Every call site already guards `password.isEmpty` and passes null for it -- meaning "no password on this device" -- which is precisely the case this now lets them see. An empty *email* stays rejected: it carries no identity at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG --- crdt_sync_dart/lib/src/firebase_settings.dart | 12 +++++++++++- crdt_sync_dart/pubspec.yaml | 2 +- crdt_sync_dart/test/firebase_settings_test.dart | 16 +++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crdt_sync_dart/lib/src/firebase_settings.dart b/crdt_sync_dart/lib/src/firebase_settings.dart index 3bbe65a..1bcfc49 100644 --- a/crdt_sync_dart/lib/src/firebase_settings.dart +++ b/crdt_sync_dart/lib/src/firebase_settings.dart @@ -80,7 +80,17 @@ class FirebaseAccount { final email = decoded['email']; final password = decoded['password']; if (email is! String || password is! String) return null; - if (email.isEmpty || password.isEmpty) return null; + // An empty password is legal, and rejecting it was a real bug: both the + // Google one-tap path and the seeded-session path deliberately store + // `password: ''` -- the refresh token, not an address or a password, is + // the credential on those devices. Refusing to parse the blob they had + // just written made every such device read back as "not configured" on + // the next launch, so a signed-in phone fell through to the mirror + // forever. Callers already handle it: each guards `password.isEmpty` and + // passes null, meaning "no password on this device". + // + // An empty *email* stays rejected: it carries no identity at all. + if (email.isEmpty) return null; return FirebaseAccount(email: email, password: password); } diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index 3374161..55c2944 100644 --- a/crdt_sync_dart/pubspec.yaml +++ b/crdt_sync_dart/pubspec.yaml @@ -2,7 +2,7 @@ name: crdt_sync description: >- Shared CRDT merge scheme + GitHub-Contents-API sync transport for cross-device apps. Pure Dart, no Flutter dependency. -version: 0.10.0 +version: 0.11.0 publish_to: 'none' environment: diff --git a/crdt_sync_dart/test/firebase_settings_test.dart b/crdt_sync_dart/test/firebase_settings_test.dart index 386a92b..972cccf 100644 --- a/crdt_sync_dart/test/firebase_settings_test.dart +++ b/crdt_sync_dart/test/firebase_settings_test.dart @@ -67,11 +67,25 @@ void main() { ('a missing password', '{"email":"a@b.c"}'), ('a non-string email', '{"email":1,"password":"p"}'), ('an empty email', '{"email":"","password":"p"}'), - ('an empty password', '{"email":"a@b.c","password":""}'), ]) { test('treats $name as not configured', () { expect(FirebaseAccount.tryParse(raw), isNull); }); } + + test('accepts an empty password, which Google sign-in writes', () { + // Regression: this used to return null, so a device that signed in with + // Google -- or adopted a seeded session -- wrote an account marker it + // could never read back. Every later launch reported "not configured" + // and fell through to the mirror, while a perfectly good refresh token + // sat unused in the keystore beside it. + final parsed = FirebaseAccount.tryParse( + const FirebaseAccount(email: 'a@b.c', password: '').toJsonString(), + ); + + expect(parsed, isNotNull); + expect(parsed!.email, 'a@b.c'); + expect(parsed.password, isEmpty); + }); }); }