mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
crdt_sync_dart 0.5.1: a shared secure credential store
Every Flutter consumer already keeps its GitHub token in flutter_secure_storage, which is where a Firebase refresh token belongs too: it is the long-lived secret, while ID tokens expire in about an hour. Takes read/write/delete closures rather than depending on flutter_secure_storage directly -- this package is pure Dart, and a Flutter plugin dependency would make it unusable from the command-line tools and from plain `dart test`. The adapter is two lines at each call site. Keyed at `firebase.credentials`, namespaced away from each app's `sync.token` so both secrets coexist for the whole mirrored cutover. Every load failure degrades to "not signed in" rather than throwing: an app that threw here would be permanently unable to sync, with no way back short of a reinstall. 10 tests, 100% line coverage; 228 in the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
This commit is contained in:
parent
dd6e6d5571
commit
6c9cc71ae1
@ -10,5 +10,6 @@ export 'src/log.dart';
|
||||
export 'src/mirror_store.dart';
|
||||
export 'src/record.dart';
|
||||
export 'src/remote_store.dart';
|
||||
export 'src/secure_credential_store.dart';
|
||||
export 'src/store.dart';
|
||||
export 'src/sync.dart';
|
||||
|
||||
98
crdt_sync_dart/lib/src/secure_credential_store.dart
Normal file
98
crdt_sync_dart/lib/src/secure_credential_store.dart
Normal file
@ -0,0 +1,98 @@
|
||||
/// A [FirebaseCredentialStore] backed by an injected key-value secret store.
|
||||
///
|
||||
/// Every Flutter consumer of this package already keeps its GitHub token in
|
||||
/// `flutter_secure_storage` (Android Keystore / libsecret), which is exactly
|
||||
/// where a Firebase refresh token belongs too: it is the long-lived secret,
|
||||
/// while ID tokens expire in about an hour.
|
||||
///
|
||||
/// This library deliberately does **not** depend on `flutter_secure_storage`
|
||||
/// itself -- it is a pure Dart package, and pulling in a Flutter plugin would
|
||||
/// make it unusable from the command-line tools and from plain `dart test`.
|
||||
/// The app passes read/write/delete closures instead, which is a two-line
|
||||
/// adapter at the call site and keeps this file testable with no plugin and
|
||||
/// no platform channel.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'firebase_auth_rest.dart';
|
||||
|
||||
/// Reads a secret by key, returning null when absent.
|
||||
typedef SecretReader = Future<String?> Function(String key);
|
||||
|
||||
/// Writes a secret under a key.
|
||||
typedef SecretWriter = Future<void> Function(String key, String value);
|
||||
|
||||
/// Deletes a secret by key.
|
||||
typedef SecretDeleter = Future<void> Function(String key);
|
||||
|
||||
/// Persists Firebase credentials through a platform secret store.
|
||||
///
|
||||
/// Usage from a Flutter app, where `_secure` is a `FlutterSecureStorage`:
|
||||
///
|
||||
/// ```dart
|
||||
/// SecureCredentialStore(
|
||||
/// read: (k) => _secure.read(key: k),
|
||||
/// write: (k, v) => _secure.write(key: k, value: v),
|
||||
/// delete: (k) => _secure.delete(key: k),
|
||||
/// )
|
||||
/// ```
|
||||
class SecureCredentialStore implements FirebaseCredentialStore {
|
||||
SecureCredentialStore({
|
||||
required this.read,
|
||||
required this.write,
|
||||
required this.delete,
|
||||
this.key = defaultKey,
|
||||
});
|
||||
|
||||
/// Where the credentials live in the secret store.
|
||||
///
|
||||
/// Namespaced away from each app's `sync.token` (its GitHub PAT) so the two
|
||||
/// can coexist for the whole mirrored cutover period.
|
||||
static const defaultKey = 'firebase.credentials';
|
||||
|
||||
/// Reads the credentials blob from the platform secret store.
|
||||
final SecretReader read;
|
||||
|
||||
/// Writes the credentials blob to the platform secret store.
|
||||
final SecretWriter write;
|
||||
|
||||
/// Removes the credentials blob from the platform secret store.
|
||||
final SecretDeleter delete;
|
||||
|
||||
/// The secret-store key used for the credentials blob.
|
||||
final String key;
|
||||
|
||||
/// Returns the stored credentials, or null if absent or unreadable.
|
||||
///
|
||||
/// A corrupt or half-written value reads as "not signed in" rather than
|
||||
/// throwing: the caller's next step is to sign in again, which repairs it.
|
||||
/// Failing here instead would leave an app permanently unable to sync with
|
||||
/// no way back short of a reinstall.
|
||||
@override
|
||||
Future<FirebaseCredentials?> load() async {
|
||||
final String? raw;
|
||||
try {
|
||||
raw = await read(key);
|
||||
} on Exception {
|
||||
return null;
|
||||
}
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
return FirebaseCredentials.fromJson(decoded);
|
||||
} on FormatException {
|
||||
return null;
|
||||
} on TypeError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(FirebaseCredentials credentials) =>
|
||||
write(key, jsonEncode(credentials.toJson()));
|
||||
|
||||
@override
|
||||
Future<void> clear() => delete(key);
|
||||
}
|
||||
@ -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.5.0
|
||||
version: 0.5.1
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
|
||||
117
crdt_sync_dart/test/secure_credential_store_test.dart
Normal file
117
crdt_sync_dart/test/secure_credential_store_test.dart
Normal file
@ -0,0 +1,117 @@
|
||||
/// Tests for the platform-secret-backed credential store.
|
||||
///
|
||||
/// The load path degrades to "not signed in" on every failure rather than
|
||||
/// throwing: an app that threw here would be permanently unable to sync, with
|
||||
/// no way back short of a reinstall.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// An in-memory stand-in for `flutter_secure_storage`.
|
||||
class _FakeSecrets {
|
||||
final Map<String, String> values = {};
|
||||
bool readThrows = false;
|
||||
|
||||
Future<String?> read(String key) async {
|
||||
if (readThrows) throw const FormatException('keystore unavailable');
|
||||
return values[key];
|
||||
}
|
||||
|
||||
Future<void> write(String key, String value) async => values[key] = value;
|
||||
|
||||
Future<void> delete(String key) async => values.remove(key);
|
||||
}
|
||||
|
||||
SecureCredentialStore _store(_FakeSecrets secrets, {String? key}) =>
|
||||
SecureCredentialStore(
|
||||
read: secrets.read,
|
||||
write: secrets.write,
|
||||
delete: secrets.delete,
|
||||
key: key ?? SecureCredentialStore.defaultKey,
|
||||
);
|
||||
|
||||
FirebaseCredentials _creds() => FirebaseCredentials(
|
||||
idToken: 'id',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.utc(2026, 8, 5, 7, 45),
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('round-trips credentials', () async {
|
||||
final secrets = _FakeSecrets();
|
||||
|
||||
await _store(secrets).save(_creds());
|
||||
final loaded = await _store(secrets).load();
|
||||
|
||||
expect(loaded, isNotNull);
|
||||
expect(loaded!.refreshToken, 'refresh');
|
||||
expect(loaded.idToken, 'id');
|
||||
expect(loaded.expiresAt, DateTime.utc(2026, 8, 5, 7, 45));
|
||||
});
|
||||
|
||||
test('does not collide with the app GitHub token key', () async {
|
||||
// Both secrets coexist for the whole mirrored cutover period.
|
||||
final secrets = _FakeSecrets();
|
||||
secrets.values['sync.token'] = 'github-pat';
|
||||
|
||||
await _store(secrets).save(_creds());
|
||||
|
||||
expect(secrets.values['sync.token'], 'github-pat');
|
||||
expect(secrets.values.containsKey('firebase.credentials'), isTrue);
|
||||
});
|
||||
|
||||
test('honours a custom key', () async {
|
||||
final secrets = _FakeSecrets();
|
||||
|
||||
await _store(secrets, key: 'other').save(_creds());
|
||||
|
||||
expect(secrets.values.containsKey('other'), isTrue);
|
||||
});
|
||||
|
||||
test('reports absent credentials as not signed in', () async {
|
||||
expect(await _store(_FakeSecrets()).load(), isNull);
|
||||
});
|
||||
|
||||
test('treats an empty value as not signed in', () async {
|
||||
final secrets = _FakeSecrets()..values['firebase.credentials'] = '';
|
||||
|
||||
expect(await _store(secrets).load(), isNull);
|
||||
});
|
||||
|
||||
test('treats corrupt JSON as not signed in', () async {
|
||||
final secrets = _FakeSecrets()..values['firebase.credentials'] = '{oops';
|
||||
|
||||
expect(await _store(secrets).load(), isNull);
|
||||
});
|
||||
|
||||
test('treats a non-object value as not signed in', () async {
|
||||
final secrets = _FakeSecrets()..values['firebase.credentials'] = '[1,2]';
|
||||
|
||||
expect(await _store(secrets).load(), isNull);
|
||||
});
|
||||
|
||||
test('treats a wrong-shaped object as not signed in', () async {
|
||||
final secrets = _FakeSecrets()
|
||||
..values['firebase.credentials'] = jsonEncode({'id_token': 'only'});
|
||||
|
||||
expect(await _store(secrets).load(), isNull);
|
||||
});
|
||||
|
||||
test('treats an unreadable keystore as not signed in', () async {
|
||||
final secrets = _FakeSecrets()..readThrows = true;
|
||||
|
||||
expect(await _store(secrets).load(), isNull);
|
||||
});
|
||||
|
||||
test('clear removes the credentials', () async {
|
||||
final secrets = _FakeSecrets();
|
||||
await _store(secrets).save(_creds());
|
||||
|
||||
await _store(secrets).clear();
|
||||
|
||||
expect(secrets.values, isEmpty);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user