• 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

69.82
/lib/core/network/auth_notifier.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c) 2020 - 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:async';
20
import 'dart:convert';
21
import 'dart:io';
22

23
import 'package:connectivity_plus/connectivity_plus.dart';
24
import 'package:flutter/foundation.dart' show visibleForTesting;
25
import 'package:flutter/widgets.dart' show AppLifecycleListener;
26
import 'package:flutter_riverpod/flutter_riverpod.dart' show Provider;
27
import 'package:http/http.dart' as http;
28
import 'package:logging/logging.dart';
29
import 'package:package_info_plus/package_info_plus.dart';
30
import 'package:riverpod_annotation/riverpod_annotation.dart';
31
import 'package:wger/core/consts.dart';
32
import 'package:wger/core/error_dialogs.dart';
33
import 'package:wger/core/errors.dart';
34
import 'package:wger/core/exceptions/http_exception.dart';
35
import 'package:wger/core/exceptions/mfa_required_exception.dart';
36
import 'package:wger/core/helpers.dart';
37
import 'package:wger/core/http_overrides.dart';
38
import 'package:wger/core/network/auth_credentials_storage.dart';
39
import 'package:wger/core/network/auth_http_client.dart';
40
import 'package:wger/core/network/auth_state.dart';
41
import 'package:wger/core/network/jwt.dart';
42
import 'package:wger/core/network/network_provider.dart';
43
import 'package:wger/core/network/server_gating.dart';
44
import 'package:wger/core/shared_preferences.dart';
45
import 'package:wger/database/powersync/powersync.dart';
46
import 'package:wger/features/account/providers/account_notifier.dart';
47
import 'package:wger/features/account/providers/user_profile_notifier.dart';
48
import 'package:wger/features/gallery/providers/gallery_notifier.dart';
49
import 'package:wger/features/nutrition/providers/nutrition_notifier.dart';
50
import 'package:wger/features/routines/providers/routines_notifier.dart';
51
import 'package:wger/features/trophies/providers/trophy_notifier.dart';
52

53
part 'auth_notifier.g.dart';
54

55
/// `allauth.headless` `app` client endpoints, relative to the
56
/// `/allauth/app/v1/` base.
57
const HEADLESS_TOKENS_REFRESH_PATH = 'tokens/refresh';
58
const HEADLESS_AUTH_LOGIN_PATH = 'auth/login';
59
const HEADLESS_AUTH_SIGNUP_PATH = 'auth/signup';
60
const HEADLESS_AUTH_MFA_AUTHENTICATE_PATH = 'auth/2fa/authenticate';
61

62
/// `/api/v2/<this>` endpoint that mints a headless-JWT refresh token for the
63
/// authenticated user. Used by the one-shot legacy-DRF → JWT migration on
64
/// app start.
65
const ISSUE_REFRESH_TOKEN_PATH = 'issue-refresh-token';
66

67
/// Header that carries the short-lived `session_token` returned by
68
/// `auth/login` when a follow-up step (currently only 2FA) is still pending.
69
const HEADLESS_SESSION_TOKEN_HEADER = 'X-Session-Token';
70

71
/// HTTP client used by the auth notifier. Override in tests.
72
final authHttpClientProvider = Provider<http.Client>((ref) => http.Client());
60✔
73

74
@Riverpod(keepAlive: true)
75
class AuthNotifier extends _$AuthNotifier {
76
  final _logger = Logger('AuthNotifier');
77
  late http.Client _client;
78
  late AuthCredentialsStorage _storage;
79
  late ServerGating _gating;
80

81
  /// Holds the in-flight refresh future so concurrent callers share a single
82
  /// network roundtrip. Cleared in `whenComplete` so the next refresh starts
83
  /// a fresh request.
84
  Future<void>? _refreshInFlight;
85

86
  /// Completes when the most recent background revalidation has finished.
87
  /// Exposed so tests can deterministically await the fire-and-forget task.
88
  @visibleForTesting
89
  Future<void>? revalidationDone;
90

91
  /// Number of times the user-switch DB wipe has fired since the notifier
92
  /// was built. Exposed so tests can assert the user-mismatch path ran
93
  /// without having to instrument PowerSync or the filesystem.
94
  @visibleForTesting
95
  int userSwitchWipeCount = 0;
96

97
  @override
14✔
98
  Future<AuthState> build() async {
99
    _client = ref.read(authHttpClientProvider);
56✔
100
    _storage = ref.read(authCredentialsStorageProvider);
56✔
101
    _gating = ref.read(serverGatingProvider);
56✔
102
    return _tryAutoLogin();
14✔
103
  }
104

105
  /// Registers a new user and logs in via the `allauth.headless` signup
106
  /// endpoint. The response already carries the access + refresh tokens,
107
  /// so no separate login call is needed.
108
  Future<LoginActions> register({
2✔
109
    required String username,
110
    required String password,
111
    required String email,
112
    required String serverUrl,
113
    String locale = 'en',
114
  }) async {
115
    final appVersion = _currentOrBlank().applicationVersion ?? await PackageInfo.fromPlatform();
6✔
116
    final version = await _gateBeforeAuth(serverUrl, appVersion);
2✔
117
    if (version.tooOld) {
118
      return LoginActions.update;
119
    }
120
    final body = <String, String>{'username': username, 'password': password};
2✔
121
    if (email.isNotEmpty) {
2✔
122
      body['email'] = email;
1✔
123
    }
124

125
    final response = await _client.post(
4✔
126
      makeHeadlessUri(serverUrl, HEADLESS_AUTH_SIGNUP_PATH),
2✔
127
      headers: jsonApiHeaders(appVersion, {HttpHeaders.acceptLanguageHeader: locale}),
4✔
128
      body: json.encode(body),
2✔
129
    );
130
    final creds = _consumeHeadlessAuthResponse(response);
2✔
131
    return _completeLogin(creds, serverUrl, appVersion, serverVersion: version.version);
1✔
132
  }
133

134
  /// Authenticates a user.
135
  ///
136
  /// Two modes:
137
  /// 1. [refreshToken] is non-empty → treated as a refresh token the user
138
  ///    minted on the wger website. Exchanged immediately for a fresh
139
  ///    access token via the `allauth.headless` `tokens/refresh` endpoint;
140
  ///    the rotated bundle is persisted as a [JwtCredential].
141
  /// 2. Otherwise the `auth/login` endpoint is used with username +
142
  ///    password. A pending second-factor flow surfaces as
143
  ///    [MfaRequiredException] for the caller to route to the 2FA
144
  ///    challenge screen.
145
  Future<LoginActions> login(
1✔
146
    String username,
147
    String password,
148
    String serverUrl,
149
    String? refreshToken,
150
  ) async {
151
    final appVersion = _currentOrBlank().applicationVersion ?? await PackageInfo.fromPlatform();
3✔
152
    final version = await _gateBeforeAuth(serverUrl, appVersion);
1✔
153
    if (version.tooOld) {
154
      return LoginActions.update;
155
    }
156
    final creds = await _obtainCredentials(
1✔
157
      username,
158
      password,
159
      serverUrl,
160
      refreshToken,
161
      appVersion,
162
    );
163
    return _completeLogin(creds, serverUrl, appVersion, serverVersion: version.version);
×
164
  }
165

166
  /// Completes a pending second-factor challenge started by [login].
167
  ///
168
  /// Sends [code] (a TOTP code or a recovery code) plus the [sessionToken]
169
  /// returned by the prior 401 to `auth/2fa/authenticate`. On success the
170
  /// server issues the access + refresh tokens and the rest of the login
171
  /// flow (persist, gating chain, PowerSync reconnect) runs unchanged.
172
  ///
173
  /// The caller is expected to have an active [AuthState.serverUrl] from
174
  /// the preceding login attempt; pass it explicitly so the call works
175
  /// even before any state has been written.
176
  Future<LoginActions> completeMfa({
2✔
177
    required String sessionToken,
178
    required String code,
179
    required String serverUrl,
180
  }) async {
181
    final appVersion = _currentOrBlank().applicationVersion ?? await PackageInfo.fromPlatform();
6✔
182
    final version = await _gateBeforeAuth(serverUrl, appVersion);
2✔
183
    if (version.tooOld) {
184
      return LoginActions.update;
185
    }
186
    final response = await _client.post(
4✔
187
      makeHeadlessUri(serverUrl, HEADLESS_AUTH_MFA_AUTHENTICATE_PATH),
2✔
188
      headers: jsonApiHeaders(appVersion, {HEADLESS_SESSION_TOKEN_HEADER: sessionToken}),
4✔
189
      body: json.encode({'code': code}),
4✔
190
    );
191
    final creds = _consumeHeadlessAuthResponse(response);
2✔
192
    return _completeLogin(creds, serverUrl, appVersion, serverVersion: version.version);
1✔
193
  }
194

195
  /// Checks the server version before authenticating and, when the server is
196
  /// too old to log into, publishes the server-update state so the router
197
  /// shows the update screen. Returns the gate result either way; the caller
198
  /// carries the version into the logged-in state when it proceeds.
199
  Future<({String? version, bool tooOld})> _gateBeforeAuth(
3✔
200
    String serverUrl,
201
    PackageInfo appVersion,
202
  ) async {
203
    // Narrow the certificate opt-in to the server we are about to talk to. This
204
    // is the single point every auth entry point passes through, and it runs
205
    // before the first request to that host.
206
    WgerHttpOverrides.trustServer(serverUrl);
3✔
207

208
    final gate = await _gating.serverVersionGate(serverUrl);
6✔
209
    if (gate.tooOld) {
210
      _logger.info('login blocked: server ${gate.version} below $MIN_SERVER_VERSION');
×
211
      state = AsyncData(
×
212
        AuthState(
×
213
          status: AuthStatus.serverUpdateRequired,
214
          serverUrl: serverUrl,
215
          serverVersion: gate.version,
216
          applicationVersion: appVersion,
217
        ),
218
      );
219
    }
220
    return gate;
221
  }
222

223
  Future<_FreshCredentials> _obtainCredentials(
1✔
224
    String username,
225
    String password,
226
    String serverUrl,
227
    String? pastedRefreshToken,
228
    PackageInfo appVersion,
229
  ) async {
230
    if (pastedRefreshToken != null && pastedRefreshToken.isNotEmpty) {
1✔
231
      return _exchangePastedRefreshToken(pastedRefreshToken, serverUrl, appVersion);
1✔
232
    }
233

234
    final response = await _client.post(
×
235
      makeHeadlessUri(serverUrl, HEADLESS_AUTH_LOGIN_PATH),
×
236
      headers: jsonApiHeaders(appVersion),
×
237
      body: json.encode({'username': username, 'password': password}),
×
238
    );
239
    return _consumeHeadlessAuthResponse(response);
×
240
  }
241

242
  /// Exchanges a manually-pasted refresh token for a fresh access + refresh
243
  /// bundle. Rotation is on by default server-side, so the pasted token is
244
  /// invalidated as part of this call and the new refresh token is what
245
  /// ends up persisted in secure storage. Throws [WgerHttpException] when
246
  /// the server rejects the pasted token.
247
  Future<_FreshCredentials> _exchangePastedRefreshToken(
1✔
248
    String refreshToken,
249
    String serverUrl,
250
    PackageInfo appVersion,
251
  ) async {
252
    final response = await _client.post(
2✔
253
      makeHeadlessUri(serverUrl, HEADLESS_TOKENS_REFRESH_PATH),
1✔
254
      headers: jsonApiHeaders(appVersion),
1✔
255
      body: json.encode({'refresh_token': refreshToken}),
2✔
256
    );
257
    return _consumeHeadlessAuthResponse(response);
1✔
258
  }
259

260
  /// Parses the standard `allauth.headless` auth response envelope.
261
  ///
262
  /// Returns a populated [_FreshCredentials] on 200 (tokens carried in
263
  /// `meta`).
264
  ///
265
  /// Throws:
266
  /// - [MfaRequiredException] on a 401 that carries `meta.session_token`,
267
  ///   signalling that the user must complete a second factor before tokens
268
  ///   are issued.
269
  /// - [WgerHttpException] for any other status, or for malformed / partial
270
  ///   bodies on otherwise-successful responses.
271
  _FreshCredentials _consumeHeadlessAuthResponse(http.Response response) {
3✔
272
    final Map<String, dynamic> body;
273
    try {
274
      body = json.decode(response.body) as Map<String, dynamic>;
6✔
275
    } catch (_) {
276
      throw WgerHttpException(response);
×
277
    }
278

279
    if (response.statusCode == 401) {
6✔
280
      final meta = body['meta'] as Map<String, dynamic>?;
1✔
281
      final sessionToken = meta?['session_token'] as String?;
×
282
      if (sessionToken != null && sessionToken.isNotEmpty) {
×
283
        final flows = (body['data'] as Map<String, dynamic>?)?['flows'] as List<dynamic>?;
×
284
        final factors =
285
            flows
286
                ?.whereType<Map<String, dynamic>>()
×
287
                .where(
×
288
                  (f) => f['id'] == 'mfa_authenticate' && (f['is_pending'] as bool? ?? false),
×
289
                )
290
                .expand((f) => (f['types'] as List<dynamic>?)?.cast<String>() ?? const <String>[])
×
291
                .toList() ??
×
292
            const <String>[];
293
        throw MfaRequiredException(sessionToken: sessionToken, availableFactors: factors);
×
294
      }
295
      throw WgerHttpException(response);
1✔
296
    }
297

298
    if (response.statusCode != 200) {
6✔
299
      throw WgerHttpException(response);
3✔
300
    }
301

302
    // auth/login and auth/signup return the tokens under `meta`, while
303
    // tokens/refresh returns them under `data` (see allauth.headless source:
304
    // base/response.py vs tokens/response.py). Read both so this parser
305
    // works for either response shape.
306
    final meta = body['meta'] as Map<String, dynamic>?;
1✔
307
    final data = body['data'] as Map<String, dynamic>?;
1✔
308
    final accessToken = (meta?['access_token'] ?? data?['access_token']) as String?;
1✔
309
    if (accessToken == null || accessToken.isEmpty) {
1✔
310
      // 200 without tokens, likely a still-pending flow we don't know how
311
      // to drive. Surface as an HTTP error so the caller renders something.
312
      throw WgerHttpException(response);
×
313
    }
314
    return (
315
      credential: JwtCredential(
1✔
316
        accessToken: accessToken,
317
        expiresAt: jwtExp(decodeJwtPayload(accessToken)),
2✔
318
      ),
319
      refreshToken: (meta?['refresh_token'] ?? data?['refresh_token']) as String?,
1✔
320
    );
321
  }
322

323
  /// Shared post-credentials path: persist the new bundle, run the gating
324
  /// chain, swap PowerSync's connector if it was already up, invalidate the
325
  /// data providers so they refetch with the new auth.
326
  ///
327
  /// When the incoming JWT belongs to a different user than the one whose
328
  /// data sits in the local PowerSync DB, the DB is wiped before
329
  /// reconnecting. Otherwise queued CRUD ops from the previous user would
330
  /// be uploaded under the new user's credentials, which is both a leak
331
  /// and would corrupt data ownership server-side.
332
  Future<LoginActions> _completeLogin(
1✔
333
    _FreshCredentials creds,
334
    String serverUrl,
335
    PackageInfo appVersion, {
336
    String? serverVersion,
337
  }) async {
338
    // Compare the durable DB-owner marker against the incoming user. null
339
    // on the owner side means "no user data on disk" (fresh install, or the
340
    // DB was wiped), so there is nothing to leak; we only wipe on a
341
    // confirmed mismatch between two known users.
342
    final dbOwnerUserId = await _storage.dbOwnerUserId();
2✔
343
    final newUserId = creds.credential.userId;
1✔
344
    final userChanged = dbOwnerUserId != null && newUserId != null && dbOwnerUserId != newUserId;
×
345

346
    await _storage.saveJwt(
2✔
347
      credential: creds.credential,
348
      refreshToken: creds.refreshToken,
349
      serverUrl: serverUrl,
350
    );
351

352
    final status = await _gating.resolve(
2✔
353
      credential: creds.credential,
354
      serverUrl: serverUrl,
355
      appVersion: appVersion,
356
    );
357
    var newState = AuthState(
1✔
358
      status: status,
359
      credential: creds.credential,
360
      serverUrl: serverUrl,
361
      serverVersion: serverVersion,
362
      applicationVersion: appVersion,
363
    );
364

365
    if (newState.status == AuthStatus.loggedIn &&
2✔
366
        !await _gating.serverConfigSane(serverUrl: serverUrl, credential: creds.credential)) {
2✔
367
      newState = newState.copyWith(serverConfigWarning: true);
×
368
    }
369

370
    // Wipe the previous user's local DB BEFORE publishing the logged-in state,
371
    // so no listener can react to the new identity while the old user's data
372
    // and queued CRUD ops are still on disk (and uploadable under the new
373
    // credentials). Mirrors the wipe-before-publish order of _resetSession.
374
    if (newState.status == AuthStatus.loggedIn && userChanged) {
2✔
375
      _logger.info(
×
376
        'different user logging in (was $dbOwnerUserId, now $newUserId), wiping local DB',
×
377
      );
378
      await _wipeOnUserSwitch();
×
379
    }
380

381
    // Claim DB ownership for the new user. Written AFTER any wipe (and before
382
    // the state publish) so a crash mid-login can never leave the marker
383
    // pointing at a user whose data is still on disk under old ownership.
384
    if (newState.status == AuthStatus.loggedIn && newUserId != null) {
2✔
385
      await _storage.setDbOwnerUserId(newUserId);
×
386
    }
387

388
    state = AsyncData(newState);
2✔
389

390
    if (newState.status == AuthStatus.loggedIn) {
2✔
391
      await _reconnectPowerSyncIfBuilt(serverUrl);
1✔
392
      _invalidatePostLoginProviders();
1✔
393
    }
394

395
    return switch (newState.status) {
1✔
396
      AuthStatus.serverUpdateRequired || AuthStatus.appUpdateRequired => LoginActions.update,
2✔
397
      _ => LoginActions.proceed,
398
    };
399
  }
400

401
  /// Clears the server config warning flag, called from the auth screen after
402
  /// the corresponding warning dialog has been shown to the user, so it doesn't
403
  /// re-appear on the next state read.
404
  void clearServerConfigWarning() {
×
405
    final current = state.asData?.value;
×
406
    if (current != null && current.serverConfigWarning) {
×
407
      state = AsyncData(current.copyWith(serverConfigWarning: false));
×
408
    }
409
  }
410

411
  /// Re-runs the auto-login flow. Used by the recovery screens
412
  /// (`ServerUnreachableScreen`, `PowerSyncUnreachableScreen`) so the user
413
  /// can retry without restarting the app.
414
  Future<void> retryAutoLogin() async {
×
415
    state = const AsyncLoading();
×
416
    state = await AsyncValue.guard(_tryAutoLogin);
×
417
  }
418

419
  Future<AuthState> _tryAutoLogin() async {
14✔
420
    // One-shot migration: if a legacy DRF token is still on disk, swap it
421
    // for a JWT bundle now so the rest of the auto-login can take the
422
    // JWT happy path. On any failure the legacy blob is left alone and the
423
    // user falls back to the still-supported legacy code path; the next
424
    // start will try again.
425
    await _maybeMigrateLegacyToJwt();
14✔
426
    final stored = await _storage.load();
20✔
427
    if (stored == null) {
428
      _logger.info('autologin failed, no saved session');
16✔
429
      return const AuthState();
430
    }
431
    final appVersion = await PackageInfo.fromPlatform();
2✔
432
    return _resolveStoredSession(stored, appVersion);
2✔
433
  }
434

435
  /// Exchanges a legacy DRF API token for a headless-JWT bundle and
436
  /// persists the result. No-op when no legacy blob is present.
437
  ///
438
  /// Sequence:
439
  /// 1. POST to [ISSUE_REFRESH_TOKEN_PATH] authenticated with the legacy
440
  ///    `Token <key>` header. The server mints a long-lived refresh token
441
  ///    backed by a fresh Django session.
442
  /// 2. Exchange that refresh token at the standard headless
443
  ///    `tokens/refresh` endpoint for the full access bundle (reuses
444
  ///    [_exchangePastedRefreshToken]).
445
  /// 3. Persist the bundle. [AuthCredentialsStorage.saveJwt] wipes the
446
  ///    legacy `PREFS_USER` blob as a side effect, so the next load sees
447
  ///    the JWT path.
448
  ///
449
  /// Failure handling — all branches log and return without touching
450
  /// state, so a re-attempt happens on the next app start:
451
  /// - Network error: keep the DRF token, the user continues working
452
  ///   against the legacy code path until connectivity returns.
453
  /// - 401 / 403: the DRF token has been revoked server-side. Wipe the
454
  ///   legacy blob so the user is routed to login (they have no usable
455
  ///   credential left).
456
  /// - 5xx / malformed body / refresh exchange failure: keep the legacy
457
  ///   blob and retry on the next start. The server-side session row
458
  ///   minted in step 1 stays orphaned but is harmless.
459
  Future<void> _maybeMigrateLegacyToJwt() async {
14✔
460
    final stored = await _storage.load();
28✔
461
    if (stored == null || stored.credential is! LegacyCredential) {
4✔
462
      return;
463
    }
464
    final legacyCred = stored.credential as LegacyCredential;
2✔
465
    final serverUrl = stored.serverUrl;
2✔
466
    final appVersion = await PackageInfo.fromPlatform();
2✔
467

468
    _logger.info('Legacy DRF token present, attempting JWT migration');
4✔
469

470
    final http.Response response;
471
    try {
472
      response = await _client.post(
4✔
473
        makeUri(serverUrl, ISSUE_REFRESH_TOKEN_PATH, trailingSlash: false),
2✔
474
        headers: jsonApiHeaders(appVersion, {
4✔
475
          HttpHeaders.authorizationHeader: legacyCred.authHeaderValue,
2✔
476
        }),
477
      );
478
    } on Exception catch (e, s) {
2✔
479
      if (isNetworkError(e)) {
2✔
480
        _logger.info('Legacy migration: server unreachable, keeping DRF token');
4✔
481
        return;
482
      }
483
      _logger.warning('Legacy migration: exchange POST threw', e, s);
×
484
      return;
485
    }
486

487
    if (_isAuthRejection(response.statusCode)) {
×
488
      _logger.warning(
×
489
        'Legacy migration: DRF token rejected (${response.statusCode}), wiping legacy blob',
×
490
      );
491
      await _storage.clearLegacy();
×
492
      return;
493
    }
494
    if (response.statusCode != 200) {
×
495
      _logger.warning(
×
496
        'Legacy migration: unexpected status ${response.statusCode}, will retry next start',
×
497
      );
498
      return;
499
    }
500

501
    final String refreshToken;
502
    try {
503
      final body = json.decode(response.body) as Map<String, dynamic>;
×
504
      refreshToken = body['refresh_token'] as String;
×
505
    } catch (e, s) {
506
      _logger.warning('Legacy migration: malformed response body', e, s);
×
507
      return;
508
    }
509
    if (refreshToken.isEmpty) {
×
510
      _logger.warning('Legacy migration: empty refresh_token in response');
×
511
      return;
512
    }
513

514
    final _FreshCredentials freshCreds;
515
    try {
516
      freshCreds = await _exchangePastedRefreshToken(refreshToken, serverUrl, appVersion);
×
517
    } on Exception catch (e, s) {
×
518
      _logger.warning('Legacy migration: refresh token exchange failed', e, s);
×
519
      return;
520
    }
521

522
    await _storage.saveJwt(
×
523
      credential: freshCreds.credential,
524
      refreshToken: freshCreds.refreshToken,
525
      serverUrl: serverUrl,
526
    );
527
    // Claim DB ownership for the migrated user: this path logs in without
528
    // going through _completeLogin, so otherwise the marker would stay null
529
    // and a later different-user login wouldn't wipe.
530
    final migratedUserId = freshCreds.credential.userId;
×
531
    if (migratedUserId != null) {
532
      await _storage.setDbOwnerUserId(migratedUserId);
×
533
    }
534
    _logger.info('Legacy migration: successful, DRF token replaced with JWT');
×
535
  }
536

537
  /// Common path for both the headless-JWT and the legacy auto-login flows:
538
  /// probe the server, then run the full gating chain. Wipes the matching
539
  /// stored credentials on a definitive 4xx so the user is routed to login.
540
  Future<AuthState> _autoLoginWith(StoredAuth stored, PackageInfo appVersion) async {
2✔
541
    final response = await _gating.probe(
4✔
542
      credential: stored.credential,
2✔
543
      serverUrl: stored.serverUrl,
2✔
544
      appVersion: appVersion,
545
    );
546
    // Server unreachable at startup. The user already has a saved session, so
547
    // let them straight in to keep working offline.
548
    if (response == null) {
549
      _logger.info('autologin: server unreachable, continuing offline');
×
550
      return _restoredSessionState(stored, appVersion);
×
551
    }
552

553
    // The server actively rejected our token: wipe the matching credential
554
    // bundle and route to login. Only 401/403 count, a transient 5xx must not
555
    // log the user out.
556
    if (_isAuthRejection(response.statusCode)) {
4✔
557
      _logger.info('autologin failed, token rejected: ${response.statusCode}');
×
558
      await _clearStoredCredential(stored.credential);
×
559
      return AuthState(applicationVersion: appVersion);
×
560
    }
561

562
    // Any other non-200 (5xx etc.) is transient: keep the saved session.
563
    if (response.statusCode != 200) {
4✔
564
      _logger.warning(
×
565
        'autologin: probe returned ${response.statusCode}, keeping saved session',
×
566
      );
567
      return _restoredSessionState(stored, appVersion);
×
568
    }
569

570
    final versionGate = await _gating.serverVersionGate(stored.serverUrl);
6✔
571
    final status = versionGate.tooOld
572
        ? AuthStatus.serverUpdateRequired
573
        : await _gating.resolve(
4✔
574
            credential: stored.credential,
2✔
575
            serverUrl: stored.serverUrl,
2✔
576
            appVersion: appVersion,
577
          );
578
    final newState = AuthState(
1✔
579
      status: status,
580
      credential: stored.credential,
1✔
581
      serverUrl: stored.serverUrl,
1✔
582
      serverVersion: versionGate.version,
583
      applicationVersion: appVersion,
584
    );
585
    if (newState.status == AuthStatus.loggedIn) {
2✔
586
      _logger.info('autologin successful');
2✔
587
    }
588
    return newState;
589
  }
590

591
  /// Decides how a stored session enters the app. A previously synced session
592
  /// goes straight in (offline-capable) and is revalidated in the background;
593
  /// a never-synced session has no local data yet, so the server must be
594
  /// reached first through the blocking [_autoLoginWith] path.
595
  Future<AuthState> _resolveStoredSession(StoredAuth stored, PackageInfo appVersion) async {
2✔
596
    if (!await _storage.hasEverSynced()) {
4✔
597
      return _autoLoginWith(stored, appVersion);
2✔
598
    }
599

600
    _logger.info('autologin: session restored, revalidating in background');
2✔
601
    _scheduleRevalidation();
1✔
602
    return _restoredSessionState(stored, appVersion);
1✔
603
  }
604

605
  /// Builds a logged-in [AuthState] for a stored session. Polymorphism in
606
  /// [AuthCredential] keeps this branch-free across credential variants.
607
  AuthState _restoredSessionState(StoredAuth stored, PackageInfo appVersion) {
1✔
608
    return AuthState(
1✔
609
      status: AuthStatus.loggedIn,
610
      credential: stored.credential,
1✔
611
      serverUrl: stored.serverUrl,
1✔
612
      applicationVersion: appVersion,
613
    );
614
  }
615

616
  /// Whether [statusCode] means the server actively rejected our token, as
617
  /// opposed to a transient error that must not invalidate the session.
618
  bool _isAuthRejection(int statusCode) => statusCode == 401 || statusCode == 403;
6✔
619

620
  /// Clears only the storage rows that back the given credential. Used on a
621
  /// definitive auth-rejection by the server so the next start routes the
622
  /// user to the login screen without touching the *other* credential
623
  /// shape (which a parallel user on the same device might still rely on).
624
  Future<void> _clearStoredCredential(AuthCredential credential) => switch (credential) {
×
625
    LegacyCredential() => _storage.clearLegacy(),
×
626
    JwtCredential() => _storage.clearJwt(),
×
627
  };
628

629
  /// Schedules a non-blocking revalidation of the restored session.
630
  ///
631
  /// The first run is deferred to a fresh event-loop task so [build] has
632
  /// completed first. It then re-runs whenever connectivity is regained, so a
633
  /// session restored while offline still gets validated without an app
634
  /// restart. Connectivity is observed directly rather than through
635
  /// networkStatusProvider: the revalidation only needs a "connection returned"
636
  /// trigger, and auth invalidates networkStatusProvider after login (see
637
  /// [_invalidatePostLoginProviders]), so it deliberately doesn't depend on it.
638
  void _scheduleRevalidation() {
1✔
639
    revalidationDone = Future(_revalidate);
3✔
640

641
    final sub = Connectivity().onConnectivityChanged.listen((results) {
3✔
642
      final online = results.any((r) => r != ConnectivityResult.none);
×
643
      if (online) {
644
        revalidationDone = _revalidate();
×
645
      }
646
    });
647
    ref.onDispose(sub.cancel);
3✔
648

649
    // A warm resume must also revalidate: the process can stay alive in the
650
    // background for days, so the tokens may have expired without any cold
651
    // start noticing. The app is offline-first, so without this a dead
652
    // session would only surface once some server-backed action happens to
653
    // run. Gated on needsRefresh so quick app switches stay request-free.
654
    final lifecycleListener = AppLifecycleListener(
1✔
655
      onResume: () {
1✔
656
        final credential = state.asData?.value.credential;
4✔
657
        if (credential?.needsRefresh(refreshLeeway) ?? false) {
1✔
658
          _logger.fine('revalidation: app resumed with stale access token');
2✔
659
          revalidationDone = _revalidate();
2✔
660
        }
661
      },
662
    );
663
    ref.onDispose(lifecycleListener.dispose);
3✔
664
  }
665

666
  /// Revalidates the restored session against the server. Fire-and-forget: it
667
  /// never throws and only changes the state on a genuine problem (a revoked
668
  /// token, or an outdated app/server). Transient failures (offline, 5xx,
669
  /// network errors) leave the user logged in.
670
  Future<void> _revalidate() async {
1✔
671
    try {
672
      var current = state.asData?.value;
3✔
673
      if (current == null || current.status != AuthStatus.loggedIn) {
2✔
674
        return;
675
      }
676

677
      // If the access token has expired (typical after a longer offline
678
      // period) refresh first, so a still-valid refresh token isn't wasted
679
      // by a 401 on the probe below. The refresh's own failure paths will
680
      // clear the session if the refresh token is also dead. Legacy
681
      // credentials are no-op here ([AuthCredential.needsRefresh] returns
682
      // false for them).
683
      if (current.credential?.needsRefresh(refreshLeeway) ?? false) {
2✔
684
        _logger.fine('revalidation: access token within leeway, refreshing first');
2✔
685
        await refreshAccessToken();
1✔
686
        current = state.asData?.value;
3✔
687
        if (current == null || current.status != AuthStatus.loggedIn) {
2✔
688
          return;
689
        }
690
      }
691

692
      final credential = current.credential;
1✔
693
      final serverUrl = current.serverUrl;
1✔
694
      if (credential == null || serverUrl == null) {
695
        return;
696
      }
697
      final appVersion = current.applicationVersion ?? await PackageInfo.fromPlatform();
1✔
698

699
      final response = await _gating.probe(
2✔
700
        credential: credential,
701
        serverUrl: serverUrl,
702
        appVersion: appVersion,
703
      );
704
      if (response == null) {
705
        _logger.fine('revalidation: server unreachable, keeping session');
×
706
        return;
707
      }
708
      if (_isAuthRejection(response.statusCode)) {
2✔
709
        _logger.info(
2✔
710
          'revalidation: token rejected (${response.statusCode}), clearing session',
2✔
711
        );
712
        await clearSessionOnly();
1✔
713
        showSessionExpiredSnackbar();
1✔
714
        return;
715
      }
716
      if (response.statusCode != 200) {
2✔
717
        _logger.warning(
×
718
          'revalidation: probe returned ${response.statusCode}, keeping session',
×
719
        );
720
        return;
721
      }
722

723
      final versionGate = await _gating.serverVersionGate(serverUrl);
2✔
724
      if (versionGate.tooOld) {
725
        _logger.info('revalidation: server update required');
×
726
        state = AsyncData(
×
727
          current.copyWith(
×
728
            status: AuthStatus.serverUpdateRequired,
729
            serverVersion: versionGate.version,
730
          ),
731
        );
732
        return;
733
      }
734
      if (await _gating.applicationUpdateRequired(serverUrl, appVersion.version)) {
3✔
735
        _logger.info('revalidation: app update required');
×
736
        state = AsyncData(
×
737
          current.copyWith(
×
738
            status: AuthStatus.appUpdateRequired,
739
            serverVersion: versionGate.version,
740
          ),
741
        );
742
        return;
743
      }
744

745
      _logger.fine('revalidation: session still valid');
2✔
746
    } catch (e, s) {
747
      _logger.warning('revalidation failed', e, s);
2✔
748
    }
749
  }
750

751
  /// Returns the current state value or a blank default. Callers that
752
  /// mutate state should always compose on top of this.
753
  AuthState _currentOrBlank() => state.asData?.value ?? const AuthState();
18✔
754

755
  /// Invalidates every notifier that depends on the authenticated HTTP base
756
  /// provider. Call this after a successful login so the providers refetch
757
  /// with the new token instead of replaying their pre-login error state.
758
  void _invalidatePostLoginProviders() {
1✔
759
    _logger.fine('Invalidating data providers after login');
2✔
760
    ref.invalidate(accountProvider);
3✔
761
    ref.invalidate(userProfileProvider);
3✔
762
    ref.invalidate(routinesRiverpodProvider);
3✔
763
    ref.invalidate(nutritionProvider);
3✔
764
    ref.invalidate(trophyStateProvider);
3✔
765
    ref.invalidate(galleryProvider);
3✔
766
    // Re-probe reachability against the new server. NetworkStatus relies on
767
    // this invalidation to pick up the post-login server URL immediately.
768
    ref.invalidate(networkStatusProvider);
3✔
769
  }
770

771
  /// Exchanges the persisted refresh token for a fresh access/refresh pair.
772
  ///
773
  /// Single-flight: concurrent callers share one HTTP request. On any
774
  /// failure (missing refresh token, missing serverUrl, network error,
775
  /// non-200 response, malformed body) the session is cleared via
776
  /// [clearSessionOnly] so the user can re-authenticate without losing
777
  /// local data. Pure network errors keep the session intact so offline
778
  /// use continues to work.
779
  ///
780
  /// On success: `state.credential` is updated to the new JWT, the rotated
781
  /// refresh token (when present) is written to secure storage, and the
782
  /// new access token + its expiry are written to shared preferences.
783
  Future<void> refreshAccessToken() {
1✔
784
    return _refreshInFlight ??= _runRefresh().whenComplete(() {
4✔
785
      _refreshInFlight = null;
1✔
786
    });
787
  }
788

789
  Future<void> _runRefresh() async {
1✔
790
    _logger.fine('refreshAccessToken: starting');
2✔
791
    final current = _currentOrBlank();
1✔
792
    final serverUrl = current.serverUrl;
1✔
793
    if (serverUrl == null) {
794
      _logger.warning('refreshAccessToken: no serverUrl in state, clearing session');
×
795
      await clearSessionOnly();
×
796
      return;
797
    }
798

799
    final String? refreshToken;
800
    try {
801
      refreshToken = await _storage.readRefreshToken();
2✔
802
    } on Exception catch (e, s) {
1✔
803
      // Secure storage can fail to decrypt the token, e.g. after an Android
804
      // backup/restore onto a new device leaves the encrypted blob behind, etc.
805
      _logger.warning('refreshAccessToken: secure storage read failed, clearing session', e, s);
2✔
806
      await clearSessionOnly();
1✔
807
      showSessionExpiredSnackbar();
1✔
808
      return;
809
    }
810
    if (refreshToken == null || refreshToken.isEmpty) {
1✔
811
      _logger.warning('refreshAccessToken: no refresh token in secure storage, clearing session');
2✔
812
      await clearSessionOnly();
1✔
813
      return;
814
    }
815

816
    final appVersion = current.applicationVersion ?? await PackageInfo.fromPlatform();
1✔
817
    final http.Response response;
818
    try {
819
      response = await _client.post(
2✔
820
        makeHeadlessUri(serverUrl, HEADLESS_TOKENS_REFRESH_PATH),
1✔
821
        headers: jsonApiHeaders(appVersion),
1✔
822
        body: json.encode({'refresh_token': refreshToken}),
2✔
823
      );
824
    } on Exception catch (e, s) {
1✔
825
      _logger.warning(
2✔
826
        'refreshAccessToken: network error, keeping session so local data stays accessible',
827
        e,
828
        s,
829
      );
830
      return;
831
    }
832

833
    if (response.statusCode != 200) {
2✔
834
      final bodySnippet = response.body.length > 200
3✔
835
          ? '${response.body.substring(0, 200)}...'
×
836
          : response.body;
1✔
837
      _logger.warning(
2✔
838
        'refreshAccessToken: status ${response.statusCode}, body: $bodySnippet, clearing session',
2✔
839
      );
840
      await clearSessionOnly();
1✔
841
      showSessionExpiredSnackbar();
1✔
842
      return;
843
    }
844

845
    final String newAccess;
846
    final String? newRefresh;
847
    final DateTime? newExp;
848
    try {
849
      final body = json.decode(response.body) as Map<String, dynamic>;
2✔
850
      // tokens/refresh returns the new tokens under `data` (see
851
      // allauth.headless.tokens.response.RefreshTokenResponse); auth/login
852
      // returns them under `meta`. Read whichever is present so this code
853
      // keeps working if either response shape changes.
854
      final data = body['data'] as Map<String, dynamic>?;
1✔
855
      final meta = body['meta'] as Map<String, dynamic>?;
1✔
856
      newAccess = (data?['access_token'] ?? meta?['access_token']) as String;
1✔
857
      newRefresh = (data?['refresh_token'] ?? meta?['refresh_token']) as String?;
2✔
858
      newExp = jwtExp(decodeJwtPayload(newAccess));
2✔
859
    } catch (e, s) {
860
      // Don't log the body: on the success-shaped path it holds the rotated
861
      // refresh token
862
      _logger.warning(
3✔
863
        'refreshAccessToken: malformed response body, clearing session. '
864
        'Status: ${response.statusCode}, body length: ${response.body.length}',
3✔
865
        e,
866
        s,
867
      );
868
      await clearSessionOnly();
1✔
869
      showSessionExpiredSnackbar();
1✔
870
      return;
871
    }
872
    _logger.fine(
3✔
873
      'refreshAccessToken: success, new expiry $newExp, '
874
      'rotated refresh token: ${newRefresh != null}',
875
    );
876

877
    final newCred = JwtCredential(accessToken: newAccess, expiresAt: newExp);
1✔
878
    await _storage.updateJwt(credential: newCred, refreshToken: newRefresh);
2✔
879
    state = AsyncData(current.copyWith(credential: newCred));
4✔
880
  }
881

882
  /// User-driven logout. Always wipes credentials; the local PowerSync data is
883
  /// kept by default and only wiped when the user opts out
884
  /// ([AuthCredentialsStorage.keepDataOnLogout]).
885
  /// Keeping it lets the same user sign back in and resume the sync
886
  /// incrementally instead of re-downloading everything; the DB-owner marker is
887
  /// preserved so a *different* user signing in still triggers a wipe.
888
  ///
889
  /// Use for the explicit "Logout" buttons in the UI. For an involuntary
890
  /// session loss (refresh-token expired, repeated 401, etc.) call
891
  /// [clearSessionOnly] instead, which always keeps the local DB so the user
892
  /// can re-authenticate without losing queued writes or cached read data.
893
  Future<void> logout() async {
2✔
894
    final keepData = await _storage.keepDataOnLogout();
4✔
895
    await _resetSession(wipeLocalData: !keepData);
2✔
896
  }
897

898
  /// Involuntary session loss: clears credentials but **keeps** the local
899
  /// PowerSync DB so the user can sign back in without losing queued
900
  /// writes or cached read data. PowerSync is disconnected, not cleared,
901
  /// and [PREFS_HAS_EVER_SYNCED] is preserved so the next auto-login
902
  /// takes the offline-friendly restored-session path.
903
  ///
904
  /// Called from refresh-token failures, repeated 401s on the HTTP
905
  /// client, and revalidation rejections. The UI logout button must use
906
  /// [logout] instead, which performs a full wipe.
907
  Future<void> clearSessionOnly() => _resetSession(wipeLocalData: false, sessionExpired: true);
2✔
908

909
  /// Shared body for [logout] and [clearSessionOnly]. PowerSync is touched
910
  /// before the state mutation so a reader observing the post-reset state
911
  /// can never race ahead and re-attach to a DB we're about to wipe.
912
  ///
913
  /// [sessionExpired] marks the reset as involuntary in the published state,
914
  /// so the login screen can tell the user why they were logged out.
915
  Future<void> _resetSession({required bool wipeLocalData, bool sessionExpired = false}) async {
2✔
916
    _logger.fine(wipeLocalData ? 'logging out' : 'clearing session, keeping local DB');
4✔
917

918
    // A failed wipe still logs the user out, but the data is left on disk: the
919
    // owner marker must then survive so a later different user is detected as a
920
    // switch and re-wipes, rather than docking onto the leftover data.
921
    var wiped = true;
922
    if (wipeLocalData) {
923
      try {
924
        await _wipeLocalDb();
×
925
      } catch (e, s) {
926
        _logger.severe('logout wipe failed, keeping owner marker', e, s);
×
927
        wiped = false;
928
      }
929
    } else {
930
      await _disconnectPowerSyncIfBuilt();
2✔
931
    }
932

933
    state = AsyncData(
4✔
934
      AuthState(
2✔
935
        applicationVersion: _currentOrBlank().applicationVersion,
4✔
936
        sessionExpired: sessionExpired,
937
      ),
938
    );
939
    if (wipeLocalData) {
940
      await _storage.clearAll();
×
941
      // Drop the marker only when the data was actually removed, keeping the
942
      // "null marker ⟺ no data" invariant. Kept on the credentials-only path
943
      // so a returning user is recognised.
944
      if (wiped) {
945
        await _storage.setDbOwnerUserId(null);
×
946
      }
947
    } else {
948
      await _storage.clearCredentials();
4✔
949
    }
950
  }
951

952
  /// Disconnects an already-built PowerSync DB but keeps its data on
953
  /// disk so a subsequent login can re-attach to the same database.
954
  /// No-op when PowerSync hasn't been built yet.
955
  Future<void> _disconnectPowerSyncIfBuilt() async {
2✔
956
    final db = builtPowerSyncInstance;
2✔
957
    if (db == null) {
958
      return;
959
    }
960
    try {
961
      await db.disconnect();
×
962
    } catch (e, s) {
963
      _logger.warning('PowerSync disconnect failed', e, s);
×
964
    }
965
  }
966

967
  /// Wipes the local PowerSync data when a different user logs in.
968
  Future<void> _wipeOnUserSwitch() async {
×
969
    userSwitchWipeCount++;
×
970
    await _wipeLocalDb();
×
971
  }
972

973
  /// Removes the local PowerSync data whether or not the DB has been built:
974
  /// when the instance exists we use PowerSync's own `disconnectAndClear`,
975
  /// otherwise (cold start, before any data widget has built it) we delete
976
  /// the on-disk files directly so no data survives.
977
  ///
978
  /// Throws if the wipe fails. Callers must abort before advancing the DB
979
  /// owner marker, otherwise the previous user's data stay on disk
980
  Future<void> _wipeLocalDb() async {
×
981
    final db = builtPowerSyncInstance;
×
982
    if (db != null) {
983
      try {
984
        await db.disconnectAndClear();
×
985
      } catch (e, s) {
986
        _logger.severe('local DB wipe via disconnectAndClear failed', e, s);
×
987
        rethrow;
988
      }
989
      return;
990
    }
991
    try {
992
      await deletePowerSyncDatabaseFile();
×
993
    } catch (e, s) {
994
      _logger.severe('local DB wipe via file delete failed', e, s);
×
995
      rethrow;
996
    }
997
  }
998

999
  /// Reconnects an already-built PowerSync DB with a fresh connector for the
1000
  /// given [serverUrl]. No-op when PowerSync hasn't been built yet: the next
1001
  /// access will build it with the current (post-login) auth state.
1002
  Future<void> _reconnectPowerSyncIfBuilt(String serverUrl) async {
1✔
1003
    final db = builtPowerSyncInstance;
1✔
1004
    if (db == null) {
1005
      return;
1006
    }
1007
    try {
1008
      connectPowerSync(db, serverUrl, ref.read(authenticatedHttpClientProvider));
×
1009
    } catch (e, s) {
1010
      _logger.warning('PowerSync reconnect failed', e, s);
×
1011
    }
1012
  }
1013

1014
  /// Refreshes the server version into the state.
1015
  Future<void> setServerVersion() async {
×
1016
    final current = _currentOrBlank();
×
1017
    if (current.serverUrl == null) {
×
1018
      return;
1019
    }
1020
    final v = await _gating.fetchServerVersion(current.serverUrl!);
×
1021
    state = AsyncData(current.copyWith(serverVersion: v));
×
1022
  }
1023

1024
  /// Loads the last server URL the user successfully logged in with.
1025
  static Future<String> getServerUrlFromPrefs() async {
1✔
1026
    final prefs = PreferenceHelper.asyncPref;
1✔
1027
    if (!(await prefs.containsKey(PREFS_LAST_SERVER))) {
1✔
1028
      return DEFAULT_SERVER_PROD;
1029
    }
1030

1031
    final userData = json.decode((await prefs.getString(PREFS_LAST_SERVER))!);
×
1032
    return userData['serverUrl'] as String;
×
1033
  }
1034
}
1035

1036
/// In-memory bundle returned by the login / signup flows. Always carries a
1037
/// [JwtCredential] (fresh logins go through `allauth.headless`); the refresh
1038
/// token is the one the caller still needs to write to secure storage.
1039
typedef _FreshCredentials = ({JwtCredential credential, String? refreshToken});
1040

1041
/// User-agent header string identifying the app/version/platform.
1042
String getAppNameHeader(PackageInfo? applicationVersion) {
9✔
1043
  String out = '';
1044
  if (applicationVersion != null) {
1045
    out =
5✔
1046
        '/${applicationVersion.version} '
5✔
1047
        '(${applicationVersion.packageName}; '
5✔
1048
        'build: ${applicationVersion.buildNumber}; '
5✔
1049
        'platform: ${Platform.operatingSystem})'
5✔
1050
        ' - https://github.com/wger-project';
1051
  }
1052
  return 'wger App$out';
9✔
1053
}
1054

1055
/// Standard JSON-API headers for the auth endpoints. The `Accept` header keeps
1056
/// a bot wall (e.g. Anubis) from serving an HTML challenge to these endpoints
1057
/// instead of JSON. [extra] adds per-request headers (session token, auth, ...).
1058
Map<String, String> jsonApiHeaders(PackageInfo? appVersion, [Map<String, String>? extra]) {
5✔
1059
  return {
5✔
1060
    HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8',
5✔
1061
    HttpHeaders.acceptHeader: 'application/json',
5✔
1062
    HttpHeaders.userAgentHeader: getAppNameHeader(appVersion),
10✔
1063
    ...?extra,
5✔
1064
  };
1065
}
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