custom_auth_manager.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import 'dart:async';
  2. import 'dart:convert';
  3. import 'package:flutter/foundation.dart';
  4. import 'package:shared_preferences/shared_preferences.dart';
  5. import '/backend/schema/structs/index.dart';
  6. import 'custom_auth_user_provider.dart';
  7. export 'custom_auth_manager.dart';
  8. const _kAuthTokenKey = '_auth_authentication_token_';
  9. const _kRefreshTokenKey = '_auth_refresh_token_';
  10. const _kTokenExpirationKey = '_auth_token_expiration_';
  11. const _kUidKey = '_auth_uid_';
  12. const _kUserDataKey = '_auth_user_data_';
  13. // Set while a sign out is in effect. Its presence makes any session values
  14. // that survived the sign out unusable, so a failed cleanup cannot resurrect
  15. // them on the next launch.
  16. const _kSignedOutKey = '_auth_signed_out_';
  17. class CustomAuthManager {
  18. // Auth session attributes
  19. String? authenticationToken;
  20. String? refreshToken;
  21. DateTime? tokenExpiration;
  22. // User attributes
  23. String? uid;
  24. UserStruct? userData;
  25. Future signOut() async {
  26. // Record the sign out before changing anything else. Once this marker is
  27. // stored, initialize() refuses to restore the session on the next launch,
  28. // so the sign out cannot be undone even if clearing the stored values
  29. // below fails. If the marker cannot be stored we have not touched the
  30. // in-memory session or the user stream yet, so nothing is left
  31. // half-signed-out: the caller is simply told the sign out did not happen.
  32. final markError = await _recordSignedOut();
  33. if (markError != null) {
  34. throw StateError(
  35. 'Sign out could not be recorded, so you are still signed in: $markError',
  36. );
  37. }
  38. authenticationToken = null;
  39. refreshToken = null;
  40. tokenExpiration = null;
  41. uid = null;
  42. userData = null;
  43. // Update the current user.
  44. uitgaanskrantAuthUserSubject.add(
  45. UitgaanskrantAuthUser(loggedIn: false),
  46. );
  47. // Clearing the stored values is best effort from here: the marker has
  48. // already made the session unusable, so a failure only leaves inert data
  49. // behind for the next launch to clean up.
  50. await _clearPersistedSession();
  51. }
  52. Future<UitgaanskrantAuthUser?> signIn({
  53. String? authenticationToken,
  54. String? refreshToken,
  55. DateTime? tokenExpiration,
  56. String? authUid,
  57. UserStruct? userData,
  58. }) async =>
  59. await _updateCurrentUser(
  60. authenticationToken: authenticationToken,
  61. refreshToken: refreshToken,
  62. tokenExpiration: tokenExpiration,
  63. authUid: authUid,
  64. userData: userData,
  65. );
  66. Future<void> updateAuthUserData({
  67. String? authenticationToken,
  68. String? refreshToken,
  69. DateTime? tokenExpiration,
  70. String? authUid,
  71. UserStruct? userData,
  72. }) async {
  73. assert(
  74. currentUser?.loggedIn ?? false,
  75. 'User must be logged in to update auth user data.',
  76. );
  77. await _updateCurrentUser(
  78. authenticationToken: authenticationToken,
  79. refreshToken: refreshToken,
  80. tokenExpiration: tokenExpiration,
  81. authUid: authUid,
  82. userData: userData,
  83. );
  84. }
  85. Future<UitgaanskrantAuthUser?> _updateCurrentUser({
  86. String? authenticationToken,
  87. String? refreshToken,
  88. DateTime? tokenExpiration,
  89. String? authUid,
  90. UserStruct? userData,
  91. }) async {
  92. this.authenticationToken = authenticationToken;
  93. this.refreshToken = refreshToken;
  94. this.tokenExpiration = tokenExpiration;
  95. this.uid = authUid;
  96. this.userData = userData;
  97. // Update the current user stream.
  98. final updatedUser = UitgaanskrantAuthUser(
  99. loggedIn: true,
  100. uid: authUid,
  101. userData: userData,
  102. );
  103. uitgaanskrantAuthUserSubject.add(updatedUser);
  104. await persistAuthData();
  105. return updatedUser;
  106. }
  107. late SharedPreferences _prefs;
  108. Future initialize() async {
  109. _prefs = await SharedPreferences.getInstance();
  110. try {
  111. if (_prefs.getBool(_kSignedOutKey) ?? false) {
  112. // A previous sign out was recorded but may not have finished clearing
  113. // the stored session. Restore nothing, and retry the cleanup, which
  114. // removes the marker once it succeeds.
  115. await _clearPersistedSession();
  116. uitgaanskrantAuthUserSubject.add(
  117. UitgaanskrantAuthUser(loggedIn: false),
  118. );
  119. return;
  120. }
  121. authenticationToken = _prefs.getString(_kAuthTokenKey);
  122. refreshToken = _prefs.getString(_kRefreshTokenKey);
  123. tokenExpiration = _prefs.getInt(_kTokenExpirationKey) != null
  124. ? DateTime.fromMillisecondsSinceEpoch(
  125. _prefs.getInt(_kTokenExpirationKey)!)
  126. : null;
  127. uid = _prefs.getString(_kUidKey);
  128. userData = _prefs.getString(_kUserDataKey) != null
  129. ? UserStruct.fromSerializableMap(
  130. (jsonDecode(_prefs.getString(_kUserDataKey)!) as Map)
  131. .cast<String, dynamic>(),
  132. )
  133. : null;
  134. } catch (e) {
  135. if (kDebugMode) {
  136. print('Error initializing auth: $e');
  137. }
  138. return;
  139. }
  140. final authTokenExists = authenticationToken != null;
  141. final tokenExpired =
  142. tokenExpiration != null && tokenExpiration!.isBefore(DateTime.now());
  143. final updatedUser = UitgaanskrantAuthUser(
  144. loggedIn: authTokenExists && !tokenExpired,
  145. uid: uid,
  146. userData: userData,
  147. );
  148. uitgaanskrantAuthUserSubject.add(updatedUser);
  149. }
  150. /// Serializes persistence so that overlapping auth transitions -- a sign-out
  151. /// landing while a sign-in is still writing, for example -- cannot interleave
  152. /// their storage operations. Each transition's writes complete, in call
  153. /// order, before the next transition's begin.
  154. Future<void> _persistQueue = Future.value();
  155. /// Runs [task] after any persistence already in flight, returning the error
  156. /// it failed with, or null if it succeeded.
  157. ///
  158. /// This never completes with an error itself, so one failed transition
  159. /// cannot poison the queue for later ones; callers decide how to react.
  160. /// Signing in treats a failure as non-fatal -- the session is live in memory
  161. /// either way, and letting the error escape would abort the action flow that
  162. /// triggered it. Signing out treats a failure as terminal, because a session
  163. /// left behind on disk would be read back as valid on the next launch.
  164. Future<Object?> _enqueuePersistTask(Future<void> Function() task) {
  165. final pending = _persistQueue.then<Object?>((_) async {
  166. try {
  167. await task();
  168. return null;
  169. } catch (e) {
  170. if (kDebugMode) {
  171. print('Error persisting auth data: $e');
  172. }
  173. return e;
  174. }
  175. });
  176. _persistQueue = pending;
  177. return pending;
  178. }
  179. /// Removes [key], treating anything other than a confirmed removal as an
  180. /// error, and retrying a bounded number of times.
  181. ///
  182. /// Used for the credentials: one that outlives its own removal would be read
  183. /// back as a valid session on the next launch, so a removal that fails has
  184. /// to become a visible error rather than a silent one.
  185. ///
  186. /// The result of remove() is the only trustworthy signal here. Reading the
  187. /// key back would always report success: remove() drops the value from the
  188. /// in-memory preference cache synchronously, before -- and regardless of --
  189. /// the platform write, and containsKey()/getString() read that same cache.
  190. Future<void> _removeVerified(String key) async {
  191. Object? lastError;
  192. for (var attempt = 0; attempt < 3; attempt++) {
  193. if (attempt > 0) {
  194. // Back off briefly; a retry that fires immediately tends to hit the
  195. // same transient condition. Only ever reached on the failure path.
  196. await Future.delayed(Duration(milliseconds: 50 * attempt));
  197. }
  198. try {
  199. if (await _prefs.remove(key)) {
  200. return;
  201. }
  202. lastError = StateError('Removing $key reported failure.');
  203. } catch (e) {
  204. lastError = e;
  205. }
  206. }
  207. throw StateError('Could not remove $key: $lastError');
  208. }
  209. /// Stores the signed-out marker, retrying and reporting failure.
  210. ///
  211. /// Both setting and clearing the marker go through a write rather than a
  212. /// removal, because only a write answers "did this stick?" unambiguously.
  213. /// remove() forwards the platform result verbatim, so it cannot distinguish
  214. /// a key that was never there from one that would not go away, and
  215. /// containsKey() cannot stand in for that check: it reads the in-memory
  216. /// cache, which remove() clears whether or not the platform write landed.
  217. /// initialize() reads getBool(...) ?? false, so a stored false means exactly
  218. /// the same thing as an absent key.
  219. Future<void> _setSignedOutMarker(bool value) async {
  220. Object? lastError;
  221. for (var attempt = 0; attempt < 3; attempt++) {
  222. if (attempt > 0) {
  223. await Future.delayed(Duration(milliseconds: 50 * attempt));
  224. }
  225. try {
  226. if (await _prefs.setBool(_kSignedOutKey, value)) {
  227. return;
  228. }
  229. lastError =
  230. StateError('Storing the signed out marker reported failure.');
  231. } catch (e) {
  232. lastError = e;
  233. }
  234. }
  235. throw StateError('Could not store the signed out marker: $lastError');
  236. }
  237. /// Records that the user has signed out. While this marker is set,
  238. /// initialize() will not restore a session, whatever else is still stored.
  239. Future<Object?> _recordSignedOut() =>
  240. _enqueuePersistTask(() => _setSignedOutMarker(true));
  241. /// Removes every stored session value, then the signed-out marker. The
  242. /// marker is removed last, so if any removal fails the marker survives and
  243. /// the next launch retries instead of restoring a half-cleared session.
  244. Future<Object?> _clearPersistedSession() => _enqueuePersistTask(() async {
  245. await _removeVerified(_kAuthTokenKey);
  246. await _removeVerified(_kRefreshTokenKey);
  247. await _prefs.remove(_kTokenExpirationKey);
  248. await _prefs.remove(_kUidKey);
  249. await _prefs.remove(_kUserDataKey);
  250. await _prefs.remove(_kSignedOutKey);
  251. });
  252. Future<Object?> persistAuthData() {
  253. // Snapshot the session synchronously, before the first await, so that a
  254. // transition landing while this write waits its turn in the queue cannot
  255. // tear the values it persists. These locals shadow the fields of the same
  256. // name, so the write below cannot read the mutable state by accident.
  257. final authenticationToken = this.authenticationToken;
  258. final refreshToken = this.refreshToken;
  259. final tokenExpiration = this.tokenExpiration;
  260. final uid = this.uid;
  261. final userData = this.userData;
  262. return _enqueuePersistTask(() async {
  263. // The auth token goes first: it is what initialize() reads to decide
  264. // whether a session exists, so clearing it first means an interrupted
  265. // sign out still reads as signed out.
  266. authenticationToken != null
  267. ? await _prefs.setString(_kAuthTokenKey, authenticationToken)
  268. : await _removeVerified(_kAuthTokenKey);
  269. refreshToken != null
  270. ? await _prefs.setString(_kRefreshTokenKey, refreshToken)
  271. : await _removeVerified(_kRefreshTokenKey);
  272. tokenExpiration != null
  273. ? await _prefs.setInt(
  274. _kTokenExpirationKey, tokenExpiration.millisecondsSinceEpoch)
  275. : await _prefs.remove(_kTokenExpirationKey);
  276. uid != null
  277. ? await _prefs.setString(_kUidKey, uid)
  278. : await _prefs.remove(_kUidKey);
  279. userData != null
  280. ? await _prefs.setString(
  281. _kUserDataKey, jsonEncode(userData.toSerializableMap()))
  282. : await _prefs.remove(_kUserDataKey);
  283. if (authenticationToken != null) {
  284. // Signing back in supersedes any recorded sign out. Written as false
  285. // rather than removed, and unconditionally: a sign out earlier in this
  286. // same run may have failed to remove the marker from storage while
  287. // still clearing it from the cache, so neither containsKey() nor a
  288. // removal can tell us whether the marker is really gone. Writing false
  289. // is unambiguous, and reads treat it exactly like an absent key.
  290. //
  291. // Done last, so the session only becomes restorable once all of it has
  292. // been stored. The cleanup in _clearPersistedSession() does not need
  293. // this: a marker that survives there keeps the veto in place and the
  294. // next launch retries, which is the safe direction, whereas here a
  295. // surviving marker would discard a session the user legitimately has.
  296. await _setSignedOutMarker(false);
  297. }
  298. });
  299. }
  300. }
  301. UitgaanskrantAuthUser? currentUser;
  302. bool get loggedIn => currentUser?.loggedIn ?? false;