| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- import 'dart:async';
- import 'dart:convert';
- import 'package:flutter/foundation.dart';
- import 'package:shared_preferences/shared_preferences.dart';
- import '/backend/schema/structs/index.dart';
- import 'custom_auth_user_provider.dart';
- export 'custom_auth_manager.dart';
- const _kAuthTokenKey = '_auth_authentication_token_';
- const _kRefreshTokenKey = '_auth_refresh_token_';
- const _kTokenExpirationKey = '_auth_token_expiration_';
- const _kUidKey = '_auth_uid_';
- const _kUserDataKey = '_auth_user_data_';
- // Set while a sign out is in effect. Its presence makes any session values
- // that survived the sign out unusable, so a failed cleanup cannot resurrect
- // them on the next launch.
- const _kSignedOutKey = '_auth_signed_out_';
- class CustomAuthManager {
- // Auth session attributes
- String? authenticationToken;
- String? refreshToken;
- DateTime? tokenExpiration;
- // User attributes
- String? uid;
- UserStruct? userData;
- Future signOut() async {
- // Record the sign out before changing anything else. Once this marker is
- // stored, initialize() refuses to restore the session on the next launch,
- // so the sign out cannot be undone even if clearing the stored values
- // below fails. If the marker cannot be stored we have not touched the
- // in-memory session or the user stream yet, so nothing is left
- // half-signed-out: the caller is simply told the sign out did not happen.
- final markError = await _recordSignedOut();
- if (markError != null) {
- throw StateError(
- 'Sign out could not be recorded, so you are still signed in: $markError',
- );
- }
- authenticationToken = null;
- refreshToken = null;
- tokenExpiration = null;
- uid = null;
- userData = null;
- // Update the current user.
- uitgaanskrantAuthUserSubject.add(
- UitgaanskrantAuthUser(loggedIn: false),
- );
- // Clearing the stored values is best effort from here: the marker has
- // already made the session unusable, so a failure only leaves inert data
- // behind for the next launch to clean up.
- await _clearPersistedSession();
- }
- Future<UitgaanskrantAuthUser?> signIn({
- String? authenticationToken,
- String? refreshToken,
- DateTime? tokenExpiration,
- String? authUid,
- UserStruct? userData,
- }) async =>
- await _updateCurrentUser(
- authenticationToken: authenticationToken,
- refreshToken: refreshToken,
- tokenExpiration: tokenExpiration,
- authUid: authUid,
- userData: userData,
- );
- Future<void> updateAuthUserData({
- String? authenticationToken,
- String? refreshToken,
- DateTime? tokenExpiration,
- String? authUid,
- UserStruct? userData,
- }) async {
- assert(
- currentUser?.loggedIn ?? false,
- 'User must be logged in to update auth user data.',
- );
- await _updateCurrentUser(
- authenticationToken: authenticationToken,
- refreshToken: refreshToken,
- tokenExpiration: tokenExpiration,
- authUid: authUid,
- userData: userData,
- );
- }
- Future<UitgaanskrantAuthUser?> _updateCurrentUser({
- String? authenticationToken,
- String? refreshToken,
- DateTime? tokenExpiration,
- String? authUid,
- UserStruct? userData,
- }) async {
- this.authenticationToken = authenticationToken;
- this.refreshToken = refreshToken;
- this.tokenExpiration = tokenExpiration;
- this.uid = authUid;
- this.userData = userData;
- // Update the current user stream.
- final updatedUser = UitgaanskrantAuthUser(
- loggedIn: true,
- uid: authUid,
- userData: userData,
- );
- uitgaanskrantAuthUserSubject.add(updatedUser);
- await persistAuthData();
- return updatedUser;
- }
- late SharedPreferences _prefs;
- Future initialize() async {
- _prefs = await SharedPreferences.getInstance();
- try {
- if (_prefs.getBool(_kSignedOutKey) ?? false) {
- // A previous sign out was recorded but may not have finished clearing
- // the stored session. Restore nothing, and retry the cleanup, which
- // removes the marker once it succeeds.
- await _clearPersistedSession();
- uitgaanskrantAuthUserSubject.add(
- UitgaanskrantAuthUser(loggedIn: false),
- );
- return;
- }
- authenticationToken = _prefs.getString(_kAuthTokenKey);
- refreshToken = _prefs.getString(_kRefreshTokenKey);
- tokenExpiration = _prefs.getInt(_kTokenExpirationKey) != null
- ? DateTime.fromMillisecondsSinceEpoch(
- _prefs.getInt(_kTokenExpirationKey)!)
- : null;
- uid = _prefs.getString(_kUidKey);
- userData = _prefs.getString(_kUserDataKey) != null
- ? UserStruct.fromSerializableMap(
- (jsonDecode(_prefs.getString(_kUserDataKey)!) as Map)
- .cast<String, dynamic>(),
- )
- : null;
- } catch (e) {
- if (kDebugMode) {
- print('Error initializing auth: $e');
- }
- return;
- }
- final authTokenExists = authenticationToken != null;
- final tokenExpired =
- tokenExpiration != null && tokenExpiration!.isBefore(DateTime.now());
- final updatedUser = UitgaanskrantAuthUser(
- loggedIn: authTokenExists && !tokenExpired,
- uid: uid,
- userData: userData,
- );
- uitgaanskrantAuthUserSubject.add(updatedUser);
- }
- /// Serializes persistence so that overlapping auth transitions -- a sign-out
- /// landing while a sign-in is still writing, for example -- cannot interleave
- /// their storage operations. Each transition's writes complete, in call
- /// order, before the next transition's begin.
- Future<void> _persistQueue = Future.value();
- /// Runs [task] after any persistence already in flight, returning the error
- /// it failed with, or null if it succeeded.
- ///
- /// This never completes with an error itself, so one failed transition
- /// cannot poison the queue for later ones; callers decide how to react.
- /// Signing in treats a failure as non-fatal -- the session is live in memory
- /// either way, and letting the error escape would abort the action flow that
- /// triggered it. Signing out treats a failure as terminal, because a session
- /// left behind on disk would be read back as valid on the next launch.
- Future<Object?> _enqueuePersistTask(Future<void> Function() task) {
- final pending = _persistQueue.then<Object?>((_) async {
- try {
- await task();
- return null;
- } catch (e) {
- if (kDebugMode) {
- print('Error persisting auth data: $e');
- }
- return e;
- }
- });
- _persistQueue = pending;
- return pending;
- }
- /// Removes [key], treating anything other than a confirmed removal as an
- /// error, and retrying a bounded number of times.
- ///
- /// Used for the credentials: one that outlives its own removal would be read
- /// back as a valid session on the next launch, so a removal that fails has
- /// to become a visible error rather than a silent one.
- ///
- /// The result of remove() is the only trustworthy signal here. Reading the
- /// key back would always report success: remove() drops the value from the
- /// in-memory preference cache synchronously, before -- and regardless of --
- /// the platform write, and containsKey()/getString() read that same cache.
- Future<void> _removeVerified(String key) async {
- Object? lastError;
- for (var attempt = 0; attempt < 3; attempt++) {
- if (attempt > 0) {
- // Back off briefly; a retry that fires immediately tends to hit the
- // same transient condition. Only ever reached on the failure path.
- await Future.delayed(Duration(milliseconds: 50 * attempt));
- }
- try {
- if (await _prefs.remove(key)) {
- return;
- }
- lastError = StateError('Removing $key reported failure.');
- } catch (e) {
- lastError = e;
- }
- }
- throw StateError('Could not remove $key: $lastError');
- }
- /// Stores the signed-out marker, retrying and reporting failure.
- ///
- /// Both setting and clearing the marker go through a write rather than a
- /// removal, because only a write answers "did this stick?" unambiguously.
- /// remove() forwards the platform result verbatim, so it cannot distinguish
- /// a key that was never there from one that would not go away, and
- /// containsKey() cannot stand in for that check: it reads the in-memory
- /// cache, which remove() clears whether or not the platform write landed.
- /// initialize() reads getBool(...) ?? false, so a stored false means exactly
- /// the same thing as an absent key.
- Future<void> _setSignedOutMarker(bool value) async {
- Object? lastError;
- for (var attempt = 0; attempt < 3; attempt++) {
- if (attempt > 0) {
- await Future.delayed(Duration(milliseconds: 50 * attempt));
- }
- try {
- if (await _prefs.setBool(_kSignedOutKey, value)) {
- return;
- }
- lastError =
- StateError('Storing the signed out marker reported failure.');
- } catch (e) {
- lastError = e;
- }
- }
- throw StateError('Could not store the signed out marker: $lastError');
- }
- /// Records that the user has signed out. While this marker is set,
- /// initialize() will not restore a session, whatever else is still stored.
- Future<Object?> _recordSignedOut() =>
- _enqueuePersistTask(() => _setSignedOutMarker(true));
- /// Removes every stored session value, then the signed-out marker. The
- /// marker is removed last, so if any removal fails the marker survives and
- /// the next launch retries instead of restoring a half-cleared session.
- Future<Object?> _clearPersistedSession() => _enqueuePersistTask(() async {
- await _removeVerified(_kAuthTokenKey);
- await _removeVerified(_kRefreshTokenKey);
- await _prefs.remove(_kTokenExpirationKey);
- await _prefs.remove(_kUidKey);
- await _prefs.remove(_kUserDataKey);
- await _prefs.remove(_kSignedOutKey);
- });
- Future<Object?> persistAuthData() {
- // Snapshot the session synchronously, before the first await, so that a
- // transition landing while this write waits its turn in the queue cannot
- // tear the values it persists. These locals shadow the fields of the same
- // name, so the write below cannot read the mutable state by accident.
- final authenticationToken = this.authenticationToken;
- final refreshToken = this.refreshToken;
- final tokenExpiration = this.tokenExpiration;
- final uid = this.uid;
- final userData = this.userData;
- return _enqueuePersistTask(() async {
- // The auth token goes first: it is what initialize() reads to decide
- // whether a session exists, so clearing it first means an interrupted
- // sign out still reads as signed out.
- authenticationToken != null
- ? await _prefs.setString(_kAuthTokenKey, authenticationToken)
- : await _removeVerified(_kAuthTokenKey);
- refreshToken != null
- ? await _prefs.setString(_kRefreshTokenKey, refreshToken)
- : await _removeVerified(_kRefreshTokenKey);
- tokenExpiration != null
- ? await _prefs.setInt(
- _kTokenExpirationKey, tokenExpiration.millisecondsSinceEpoch)
- : await _prefs.remove(_kTokenExpirationKey);
- uid != null
- ? await _prefs.setString(_kUidKey, uid)
- : await _prefs.remove(_kUidKey);
- userData != null
- ? await _prefs.setString(
- _kUserDataKey, jsonEncode(userData.toSerializableMap()))
- : await _prefs.remove(_kUserDataKey);
- if (authenticationToken != null) {
- // Signing back in supersedes any recorded sign out. Written as false
- // rather than removed, and unconditionally: a sign out earlier in this
- // same run may have failed to remove the marker from storage while
- // still clearing it from the cache, so neither containsKey() nor a
- // removal can tell us whether the marker is really gone. Writing false
- // is unambiguous, and reads treat it exactly like an absent key.
- //
- // Done last, so the session only becomes restorable once all of it has
- // been stored. The cleanup in _clearPersistedSession() does not need
- // this: a marker that survives there keeps the veto in place and the
- // next launch retries, which is the safe direction, whereas here a
- // surviving marker would discard a session the user legitimately has.
- await _setSignedOutMarker(false);
- }
- });
- }
- }
- UitgaanskrantAuthUser? currentUser;
- bool get loggedIn => currentUser?.loggedIn ?? false;
|