utils/crdt_sync_dart/lib/src/firebase_auth_rest.dart
Krzysztof Rudnicki 02e2e13790 crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam
Extracts the three-method remote surface GitHub was reached through into a
`RemoteStore` interface, then implements it against Firebase Realtime
Database over REST, so apps can move backend with a constructor change.

Why REST and not the SDKs: official FlutterFire has no Linux desktop
support, and `wake_alarm`'s PC side is a dependency-free systemd job. The
RTDB REST endpoints are plain HTTPS and behave identically on Linux,
Android and headless. Firebase is a transport, never the datastore -- each
app keeps its local log as the source of truth, so offline-first survives.

Why RTDB and not Firestore: on the Spark plan RTDB bills only storage and
bandwidth with no per-operation quota, so a runaway loop cannot exhaust a
daily budget and silently stop syncing mid-day.

Two savings make the no-cost tier a guarantee rather than a hope. Measured
against the GitHub-backed history this replaces: 4894 of 5542 commits
(88.3%) were byte-identical no-ops, and every tick re-downloaded every
peer's whole log. Both are now driven by one sha256 revision -- an
unchanged log is not re-pushed, and an unchanged peer is not re-fetched.
That turns egress from "ticks x log size" (~700 MB/month, and growing with
the log) into "edits" (~40 MB/month against a 10 GiB cap).

Revisions are one node per device, written only by its owner, rather than
one shared map -- a whole-map write would erase every peer's entry, after
which those peers would look permanently unchanged and never be fetched
again. Per-device keys make that impossible rather than merely avoided.

`.` is illegal in an RTDB key and the REST `.json` is a format suffix, so
paths get a reversible `~XX` escape (`log.json` -> `log~2Ejson`). Lossy
extension-stripping would break `todo`, whose listings return filenames.

`MirrorStore` / `MirrorSyncClient` dual-writes during the cutover. Reads
consult *both* backends: with primary-only reads a migrated device would
never see an un-migrated peer's writes while still mirroring its own back
-- silent one-directional convergence with no error raised. Writes are
asymmetric on purpose: a primary failure fails the tick, a mirror failure
is logged and survived.

Auth is Firebase email/password over identitytoolkit REST, with mandatory
refresh-token rotation. No service-account key anywhere: it would bypass
the security rules and is trivially extractable from an APK.

Verified live against a real database, not only mocks: sign-in and refresh,
key escaping round-tripping byte-for-byte, an unchanged tick writing
nothing, two devices converging, a revoked session throwing rather than
returning null, and a log written by the Python client being read by the
Dart one.

No app is migrated by this commit; every app still runs on GitHub.

Dart: 194 tests. Python: 203 tests at 100% branch coverage. No lint
suppressions added.
2026-08-03 18:14:25 +02:00

251 lines
8.4 KiB
Dart

/// Firebase Authentication over its REST API, with no FlutterFire dependency.
///
/// The official `firebase_auth` plugin ships iOS/Android/Web/macOS only --
/// there is no Linux desktop support -- but these apps run on Linux desktop
/// *and* Android, and one (`wake_alarm`) runs headless under systemd. The
/// REST endpoints are plain HTTPS and behave identically everywhere, so this
/// is the only auth path that covers every target.
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'remote_store.dart';
const _signInBase = 'https://identitytoolkit.googleapis.com/v1';
const _refreshBase = 'https://securetoken.googleapis.com/v1';
/// Refresh this long before the token actually expires.
///
/// An ID token lives ~1 h. Refreshing early means a tick that starts just
/// under the wire still finishes with a valid token, rather than 401ing
/// halfway through a multi-file push.
const _refreshSkew = Duration(minutes: 5);
/// Raised for an authentication failure the caller must not silently ignore.
///
/// A [RemoteSyncError] so callers that only care about "sync is broken" can
/// catch one type, while a settings screen can single this out to say
/// "your password is wrong" rather than "the network is down".
class FirebaseAuthError extends RemoteSyncError {
FirebaseAuthError(super.message);
}
/// One device's Firebase session: a short-lived ID token plus the long-lived
/// refresh token that mints the next one.
class FirebaseCredentials {
FirebaseCredentials({
required this.idToken,
required this.refreshToken,
required this.expiresAt,
});
factory FirebaseCredentials.fromJson(Map<String, dynamic> json) =>
FirebaseCredentials(
idToken: json['id_token'] as String,
refreshToken: json['refresh_token'] as String,
expiresAt: DateTime.parse(json['expires_at'] as String).toUtc(),
);
/// The bearer credential for Realtime Database requests. Expires quickly.
final String idToken;
/// The long-lived credential. **This is the secret worth protecting** --
/// store it in `flutter_secure_storage` on Android or a `0600` file on
/// Linux, never in plain `SharedPreferences`.
final String refreshToken;
final DateTime expiresAt;
/// Whether [idToken] is expired, or close enough that a tick starting now
/// might outlive it.
bool isExpiredAt(DateTime now) => !now.add(_refreshSkew).isBefore(expiresAt);
Map<String, dynamic> toJson() => {
'id_token': idToken,
'refresh_token': refreshToken,
'expires_at': expiresAt.toUtc().toIso8601String(),
};
}
/// Where a device keeps its [FirebaseCredentials] between runs.
///
/// Abstract because the right answer differs per platform, and one of the
/// callers (`wake_alarm` PC) is a **fresh process every minute** -- without
/// persistence it would re-authenticate 1440 times a day.
abstract interface class FirebaseCredentialStore {
Future<FirebaseCredentials?> load();
Future<void> save(FirebaseCredentials credentials);
Future<void> clear();
}
/// An in-memory [FirebaseCredentialStore], for tests and one-shot scripts.
class InMemoryCredentialStore implements FirebaseCredentialStore {
InMemoryCredentialStore([this._credentials]);
FirebaseCredentials? _credentials;
@override
Future<FirebaseCredentials?> load() async => _credentials;
@override
Future<void> save(FirebaseCredentials credentials) async =>
_credentials = credentials;
@override
Future<void> clear() async => _credentials = null;
}
/// Signs in and keeps a valid ID token available, refreshing as needed.
///
/// [apiKey] is the project's public Web API key -- not a secret, and safe to
/// ship inside an APK. The actual credential is the refresh token held by
/// [store]. A service-account key is deliberately **not** used: it would
/// bypass the security rules entirely and is trivially extractable from an
/// installed app.
class FirebaseTokenProvider {
FirebaseTokenProvider({
required this.apiKey,
required this.store,
http.Client? httpClient,
DateTime Function()? clock,
}) : _http = httpClient ?? http.Client(),
_clock = clock ?? DateTime.now;
final String apiKey;
/// Where the refresh token lives between runs. Public so a settings screen
/// can swap in the platform-appropriate backing store.
final FirebaseCredentialStore store;
final http.Client _http;
final DateTime Function() _clock;
FirebaseCredentials? _cached;
/// Exchanges an email/password for a session and persists it.
///
/// Called once per device from a settings screen or a setup command; every
/// later run reuses the stored refresh token.
Future<void> signIn({
required String email,
required String password,
}) async {
final body = await _post(
Uri.parse('$_signInBase/accounts:signInWithPassword?key=$apiKey'),
{'email': email, 'password': password, 'returnSecureToken': true},
'sign in',
);
await _adopt(
idToken: body['idToken'] as String,
refreshToken: body['refreshToken'] as String,
expiresInSeconds: body['expiresIn'] as String,
);
}
/// Returns a currently-valid ID token, refreshing or failing loudly.
///
/// Throws [FirebaseAuthError] when no session is stored or the refresh
/// token has been revoked -- never returns a stale token and never
/// silently no-ops, because a sync that quietly stops syncing is the
/// failure mode this whole design is trying to avoid.
Future<String> idToken() async {
final credentials = _cached ??= await store.load();
if (credentials == null) {
throw FirebaseAuthError(
'not signed in: no stored refresh token for this device',
);
}
if (!credentials.isExpiredAt(_clock())) return credentials.idToken;
return _refresh(credentials.refreshToken);
}
/// Whether this device has a stored session at all.
///
/// Distinguishes "sync is not configured" (fine -- `screen-locker` treats a
/// missing credential as a normal state) from "sync is configured and
/// broken" (an error).
Future<bool> hasSession() async =>
(_cached ??= await store.load()) != null;
/// Forgets the stored session, so the next [idToken] fails loudly.
Future<void> signOut() async {
_cached = null;
await store.clear();
}
Future<String> _refresh(String refreshToken) async {
final body = await _post(
Uri.parse('$_refreshBase/token?key=$apiKey'),
{'grant_type': 'refresh_token', 'refresh_token': refreshToken},
'refresh the session',
);
return _adopt(
idToken: body['id_token'] as String,
// A refresh may hand back a rotated refresh token; keep the new one.
refreshToken: body['refresh_token'] as String,
expiresInSeconds: body['expires_in'] as String,
);
}
Future<String> _adopt({
required String idToken,
required String refreshToken,
required String expiresInSeconds,
}) async {
final credentials = FirebaseCredentials(
idToken: idToken,
refreshToken: refreshToken,
expiresAt: _clock().toUtc().add(
Duration(seconds: int.parse(expiresInSeconds)),
),
);
_cached = credentials;
await store.save(credentials);
return idToken;
}
Future<Map<String, dynamic>> _post(
Uri uri,
Map<String, Object> payload,
String what,
) async {
late final http.Response res;
try {
res = await _http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
);
} on http.ClientException catch (exc) {
throw FirebaseAuthError('network error trying to $what: $exc');
}
if (res.statusCode < 200 || res.statusCode >= 300) {
throw FirebaseAuthError(
'failed to $what: HTTP ${res.statusCode} ${_reason(res.body)}',
);
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
/// Pulls Google's machine-readable reason (`INVALID_PASSWORD`,
/// `TOKEN_EXPIRED`, `USER_DISABLED`, ...) out of an error body, so the
/// thrown message says what actually went wrong instead of just "400".
static String _reason(String body) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final error = decoded['error'];
if (error is Map<String, dynamic>) return '(${error['message']})';
if (error is String) return '($error)';
}
} on FormatException {
// Non-JSON body (a proxy error page, say): the status code is all the
// detail there is.
}
return '';
}
void close() => _http.close();
}