• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wger-project / flutter / 30854254088

03 Aug 2026 09:21PM UTC coverage: 40.895% (-15.0%) from 55.93%
30854254088

Pull #1260

github

web-flow
Merge d93794de5 into e3affdd4f
Pull Request #1260: WIP: health sync

9980 of 24404 relevant lines covered (40.89%)

1.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.82
/lib/core/network/auth_credentials_storage.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c) 2026 wger Team
4
 *
5
 * wger Workout Manager is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU Affero General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU Affero General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU Affero General Public License
16
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
 */
18

19
import 'dart:convert';
20

21
import 'package:flutter_riverpod/flutter_riverpod.dart';
22
import 'package:logging/logging.dart';
23
import 'package:shared_preferences/shared_preferences.dart';
24
import 'package:wger/core/consts.dart';
25
import 'package:wger/core/network/auth_state.dart';
26
import 'package:wger/core/network/secure_token_storage.dart';
27
import 'package:wger/core/shared_preferences.dart';
28

29
/// Credential + server URL pair restored from on-disk storage. The refresh
30
/// token (for the JWT path) is intentionally absent: it lives in secure
31
/// storage and is only read when a refresh actually runs.
32
class StoredAuth {
33
  final AuthCredential credential;
34
  final String serverUrl;
35

36
  const StoredAuth({required this.credential, required this.serverUrl});
2✔
37
}
38

39
/// All persistence for the auth flow in one place. Holds the JWT-keyed
40
/// shared-preference bundle, the legacy `PREFS_USER` blob, and the
41
/// secure-storage refresh token. Lifts the storage layout details out of
42
/// the notifier so callers don't have to know which keys back which fact.
43
class AuthCredentialsStorage {
44
  final SecureTokenStorage _secureStorage;
45
  final _logger = Logger('AuthCredentialsStorage');
46

47
  AuthCredentialsStorage(this._secureStorage);
16✔
48

49
  SharedPreferencesAsync get _prefs => PreferenceHelper.asyncPref;
32✔
50

51
  /// Reads the persisted credential bundle. The headless-JWT keys take
52
  /// priority over the legacy `PREFS_USER` blob, so a partial migration
53
  /// state still resolves to the JWT path. Returns null when neither
54
  /// shape is fully present.
55
  Future<StoredAuth?> load() async {
14✔
56
    final jwt = await _readJwt();
14✔
57
    if (jwt != null) {
58
      return jwt;
59
    }
60
    return _readLegacy();
10✔
61
  }
62

63
  Future<StoredAuth?> _readJwt() async {
14✔
64
    final tokenType = await _prefs.getString(PREFS_TOKEN_TYPE);
24✔
65
    if (tokenType != AuthTokenType.headlessJwt.name) {
20✔
66
      return null;
67
    }
68
    final accessToken = await _prefs.getString(PREFS_ACCESS_TOKEN);
2✔
69
    final serverUrl = await _prefs.getString(PREFS_SERVER_URL);
2✔
70
    if (accessToken == null || accessToken.isEmpty || serverUrl == null || serverUrl.isEmpty) {
2✔
71
      return null;
72
    }
73
    final expiresAtMs = await _prefs.getInt(PREFS_ACCESS_EXPIRES_AT);
2✔
74
    final expiresAt = expiresAtMs == null
75
        ? null
76
        : DateTime.fromMillisecondsSinceEpoch(expiresAtMs, isUtc: true);
1✔
77
    return StoredAuth(
1✔
78
      credential: JwtCredential(accessToken: accessToken, expiresAt: expiresAt),
1✔
79
      serverUrl: serverUrl,
80
    );
81
  }
82

83
  Future<StoredAuth?> _readLegacy() async {
10✔
84
    if (!(await _prefs.containsKey(PREFS_USER))) {
20✔
85
      return null;
86
    }
87
    final raw = await _prefs.getString(PREFS_USER);
4✔
88
    if (raw == null) {
89
      return null;
90
    }
91
    final Map<String, dynamic> blob;
92
    try {
93
      blob = json.decode(raw) as Map<String, dynamic>;
2✔
94
    } catch (e, s) {
95
      _logger.warning('Could not decode PREFS_USER blob', e, s);
×
96
      return null;
97
    }
98
    final token = blob['token'] as String?;
2✔
99
    final serverUrl = blob['serverUrl'] as String?;
2✔
100
    if (token == null || serverUrl == null) {
101
      return null;
102
    }
103
    return StoredAuth(credential: LegacyCredential(token), serverUrl: serverUrl);
4✔
104
  }
105

106
  /// Persists a fresh JWT bundle. As a side effect this records the
107
  /// server URL as the "last server" for the next login screen and wipes
108
  /// the legacy `PREFS_USER` blob (legacy users transition to JWT on first
109
  /// login through this path). The DB-owner marker is intentionally NOT
110
  /// written here; the login flow sets it after any required DB wipe.
111
  Future<void> saveJwt({
1✔
112
    required JwtCredential credential,
113
    required String serverUrl,
114
    String? refreshToken,
115
  }) async {
116
    await _prefs.setString(PREFS_LAST_SERVER, json.encode({'serverUrl': serverUrl}));
4✔
117
    await _prefs.setString(PREFS_ACCESS_TOKEN, credential.accessToken);
3✔
118
    if (credential.expiresAt != null) {
1✔
119
      await _prefs.setInt(PREFS_ACCESS_EXPIRES_AT, credential.expiresAt!.millisecondsSinceEpoch);
4✔
120
    } else {
121
      await _prefs.remove(PREFS_ACCESS_EXPIRES_AT);
×
122
    }
123
    await _prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name);
3✔
124
    await _prefs.setString(PREFS_SERVER_URL, serverUrl);
2✔
125

126
    if (refreshToken != null) {
127
      try {
128
        await _secureStorage.writeRefreshToken(refreshToken);
2✔
129
      } catch (e, s) {
130
        // A locked or unavailable keyring must not abort login. The session
131
        // works now, but the refresh token cannot be persisted, so there is no
132
        // auto-login after a restart until the keyring becomes available.
133
        _logger.warning('Could not persist refresh token, auto-login disabled', e, s);
×
134
      }
135
    }
136

137
    await clearLegacy();
1✔
138
  }
139

140
  /// Updates the persisted JWT bundle in place after a successful refresh.
141
  /// Identical to [saveJwt] minus the legacy-cleanup and last-server side
142
  /// effects (the original login already wrote those).
143
  Future<void> updateJwt({
1✔
144
    required JwtCredential credential,
145
    String? refreshToken,
146
  }) async {
147
    await _prefs.setString(PREFS_ACCESS_TOKEN, credential.accessToken);
3✔
148
    if (credential.expiresAt != null) {
1✔
149
      await _prefs.setInt(PREFS_ACCESS_EXPIRES_AT, credential.expiresAt!.millisecondsSinceEpoch);
4✔
150
    } else {
151
      await _prefs.remove(PREFS_ACCESS_EXPIRES_AT);
×
152
    }
153
    if (refreshToken != null) {
154
      try {
155
        await _secureStorage.writeRefreshToken(refreshToken);
2✔
156
      } catch (e, s) {
157
        // A locked or unavailable keyring must not abort login. The session
158
        // works now, but the refresh token cannot be persisted, so there is no
159
        // auto-login after a restart until the keyring becomes available.
160
        _logger.warning('Could not persist refresh token, auto-login disabled', e, s);
×
161
      }
162
    }
163
  }
164

165
  /// Wipes the headless-JWT preference bundle and the secure-storage
166
  /// refresh token. Used both for involuntary session clears and as part
167
  /// of a full [clearAll]. The DB-owner marker is deliberately left intact:
168
  /// it tracks who owns the on-disk data, which clearing credentials does
169
  /// not change. It is reset only when the DB is actually wiped.
170
  Future<void> clearJwt() async {
3✔
171
    await _prefs.remove(PREFS_ACCESS_TOKEN);
6✔
172
    await _prefs.remove(PREFS_ACCESS_EXPIRES_AT);
6✔
173
    await _prefs.remove(PREFS_TOKEN_TYPE);
6✔
174
    await _prefs.remove(PREFS_SERVER_URL);
6✔
175
    try {
176
      await _secureStorage.deleteRefreshToken();
6✔
177
    } catch (e, s) {
178
      // A locked or unavailable keyring must not abort logout; the refresh
179
      // token is unreadable anyway, so wiping it is best-effort.
180
      _logger.warning('Could not delete refresh token, keyring unavailable', e, s);
2✔
181
    }
182
  }
183

184
  /// Wipes only the legacy `PREFS_USER` blob. Used by the JWT-migration
185
  /// path on a 401 from the exchange endpoint (DRF token revoked) and as
186
  /// a side effect of [saveJwt].
187
  Future<void> clearLegacy() async {
3✔
188
    await _prefs.remove(PREFS_USER);
6✔
189
  }
190

191
  /// Wipes both credential shapes but keeps the "has ever synced" flag,
192
  /// so the next login takes the offline-friendly restored-session path.
193
  /// Used for involuntary session loss (refresh token expired, 401
194
  /// retries exhausted) where the local PowerSync DB is preserved.
195
  Future<void> clearCredentials() async {
2✔
196
    await clearLegacy();
2✔
197
    await clearJwt();
2✔
198
  }
199

200
  /// Manual-logout wipe: clears credentials plus the "has ever synced"
201
  /// flag, so the next login takes the full first-run gating path
202
  /// (PowerSync reachability probe etc.) again.
203
  Future<void> clearAll() async {
×
204
    await clearCredentials();
×
205
    await _prefs.remove(PREFS_HAS_EVER_SYNCED);
×
206
  }
207

208
  /// JWT `sub` of the user whose data sits in the local PowerSync DB, or null
209
  /// if none. Durable across credential clears.
210
  Future<String?> dbOwnerUserId() => _prefs.getString(PREFS_DB_OWNER_USER_ID);
3✔
211

212
  /// Records (or, with null, clears) the owner of the on-disk local DB.
213
  Future<void> setDbOwnerUserId(String? userId) async {
×
214
    if (userId == null) {
215
      await _prefs.remove(PREFS_DB_OWNER_USER_ID);
×
216
    } else {
217
      await _prefs.setString(PREFS_DB_OWNER_USER_ID, userId);
×
218
    }
219
  }
220

221
  /// User preference: whether a manual logout keeps the local DB on disk.
222
  /// Defaults to [KEEP_DATA_ON_LOGOUT_DEFAULT] (keep on logout).
223
  Future<bool> keepDataOnLogout() async =>
2✔
224
      (await _prefs.getBool(PREFS_KEEP_DATA_ON_LOGOUT)) ?? KEEP_DATA_ON_LOGOUT_DEFAULT;
4✔
225

226
  /// True once a PowerSync sync has completed for the current install.
227
  /// Drives the offline-friendly fast path in auto-login.
228
  Future<bool> hasEverSynced() async => (await _prefs.getBool(PREFS_HAS_EVER_SYNCED)) ?? false;
12✔
229

230
  Future<void> markEverSynced() => _prefs.setBool(PREFS_HAS_EVER_SYNCED, true);
9✔
231

232
  /// Reads the persisted refresh token from secure storage. Returned to
233
  /// callers raw because the refresh flow needs to send it verbatim to the
234
  /// `tokens/refresh` endpoint.
235
  Future<String?> readRefreshToken() => _secureStorage.readRefreshToken();
3✔
236
}
237

238
/// Provider over the singleton storage. Reads [secureTokenStorageProvider]
239
/// for the refresh-token half; shared preferences are accessed through
240
/// [PreferenceHelper] which already manages a single async instance.
241
final authCredentialsStorageProvider = Provider<AuthCredentialsStorage>(
51✔
242
  (ref) => AuthCredentialsStorage(ref.read(secureTokenStorageProvider)),
56✔
243
);
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc