mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-12 22:30:58 +01:00
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.
251 lines
8.4 KiB
Dart
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();
|
|
}
|