• 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

41.3
/lib/core/network/auth_http_client.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c) 2026 - 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:io';
20

21
import 'package:flutter_riverpod/flutter_riverpod.dart';
22
import 'package:http/http.dart' as http;
23
import 'package:logging/logging.dart';
24
import 'package:wger/core/error_dialogs.dart';
25
import 'package:wger/core/network/auth_notifier.dart';
26
import 'package:wger/core/network/auth_state.dart';
27

28
/// Pre-emptive refresh leeway: if the access JWT will expire within this
29
/// window we refresh before sending the request. Chosen to absorb mild
30
/// client/server clock skew without burning a refresh on every call.
31
const refreshLeeway = Duration(seconds: 30);
32

33
/// HTTP client that owns the `Authorization` header for every outgoing
34
/// authenticated request to the wger backend.
35
///
36
/// Responsibilities:
37
/// - Inject the right `Authorization` value for the current credential
38
///   ([AuthCredential.authHeaderValue] does the dispatch).
39
/// - For [JwtCredential], pre-emptively refresh when the stored expiry is
40
///   within [refreshLeeway] of now.
41
/// - On a 401 reply for a *replayable* [http.Request] body that was sent
42
///   with a JWT, refresh once and retry. If the retry also returns 401
43
///   the session is treated as genuinely revoked: `onSessionExpired`
44
///   runs (clear credentials + surface a snackbar) and a synthetic 401
45
///   is returned to the caller. Non-replayable bodies (multipart /
46
///   streamed) are not retried; the pre-emptive refresh in the happy
47
///   path is the primary safeguard.
48
///
49
/// Wrapped behind [authenticatedHttpClientProvider] so consumers
50
/// (`WgerBaseProvider`, PowerSync's connector) get the auth handling for
51
/// free without needing to know about the migration state.
52
class AuthHttpClient extends http.BaseClient {
53
  final http.Client _inner;
54
  final AuthState? Function() _readAuth;
55
  final Future<void> Function() _refresh;
56
  final Future<void> Function() _onSessionExpired;
57
  final _logger = Logger('AuthHttpClient');
58

59
  AuthHttpClient({
6✔
60
    required http.Client inner,
61
    required AuthState? Function() readAuth,
62
    required Future<void> Function() refresh,
63
    required Future<void> Function() onSessionExpired,
64
  }) : _inner = inner,
65
       _readAuth = readAuth,
66
       _refresh = refresh,
67
       _onSessionExpired = onSessionExpired;
68

69
  @override
1✔
70
  Future<http.StreamedResponse> send(http.BaseRequest request) async {
71
    var credential = _readAuth()?.credential;
3✔
72

73
    if (credential?.needsRefresh(refreshLeeway) ?? false) {
1✔
74
      _logger.fine('Pre-emptive refresh: access token within leeway window');
×
75
      await _refresh();
×
76
      credential = _readAuth()?.credential;
×
77
    }
78

79
    _applyAuthHeader(request, credential);
1✔
80
    final response = await _inner.send(request);
2✔
81

82
    final canRetry =
83
        response.statusCode == 401 && credential is JwtCredential && request is http.Request;
4✔
84
    if (!canRetry) {
85
      return response;
86
    }
87

88
    _logger.fine('401 on JWT request, refreshing once and retrying');
2✔
89
    await response.stream.drain<void>();
2✔
90
    await _refresh();
2✔
91
    final fresh = _readAuth()?.credential;
3✔
92
    if (fresh is! JwtCredential) {
1✔
93
      return _syntheticUnauthorized();
1✔
94
    }
95

96
    final retry = _cloneRequest(request, fresh);
×
97
    final retryResponse = await _inner.send(retry);
×
98
    if (retryResponse.statusCode == 401) {
×
99
      _logger.warning(
×
100
        'Retry after refresh still returned 401 for '
101
        '${request.method} ${request.url.path}, treating session as revoked',
×
102
      );
103
      await retryResponse.stream.drain<void>();
×
104
      await _onSessionExpired();
×
105
      return _syntheticUnauthorized();
×
106
    }
107
    return retryResponse;
108
  }
109

110
  @override
×
111
  void close() => _inner.close();
×
112

113
  void _applyAuthHeader(http.BaseRequest req, AuthCredential? credential) {
1✔
114
    if (credential == null) {
115
      return;
116
    }
117
    req.headers[HttpHeaders.authorizationHeader] = credential.authHeaderValue;
3✔
118
  }
119

120
  http.Request _cloneRequest(http.Request orig, AuthCredential credential) {
×
121
    final retry = http.Request(orig.method, orig.url)
×
122
      ..bodyBytes = orig.bodyBytes
×
123
      ..encoding = orig.encoding
×
124
      ..followRedirects = orig.followRedirects
×
125
      ..maxRedirects = orig.maxRedirects
×
126
      ..persistentConnection = orig.persistentConnection;
×
127
    retry.headers.addAll(orig.headers);
×
128
    _applyAuthHeader(retry, credential);
×
129
    return retry;
130
  }
131

132
  http.StreamedResponse _syntheticUnauthorized() => http.StreamedResponse(
2✔
133
    const Stream<List<int>>.empty(),
134
    401,
135
    reasonPhrase: 'Authentication lost',
136
  );
137
}
138

139
/// Provider of the authenticated HTTP client used by every data-API call.
140
/// Wraps the raw client from [authHttpClientProvider] (kept separate so the
141
/// notifier itself can issue unauthenticated requests (login, refresh,
142
/// version probe) without recursing through this wrapper).
143
final authenticatedHttpClientProvider = Provider<http.Client>(
15✔
144
  (ref) => AuthHttpClient(
10✔
145
    inner: ref.watch(authHttpClientProvider),
10✔
146
    readAuth: () => ref.read(authProvider).asData?.value,
×
147
    refresh: () => ref.read(authProvider.notifier).refreshAccessToken(),
×
148
    onSessionExpired: () async {
×
149
      await ref.read(authProvider.notifier).clearSessionOnly();
×
150
      showSessionExpiredSnackbar();
×
151
    },
152
  ),
153
);
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