Skip to content

Commit 1234dd6

Browse files
authored
Merge pull request #557 from DevKor-github/feature/issue-530-token-cache
fix: cache auth tokens in local data source
2 parents bf6c75e + fd070fb commit 1234dd6

3 files changed

Lines changed: 251 additions & 2 deletions

File tree

lib/data/data_sources/token_local_data_source.dart

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:flutter/foundation.dart';
12
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
23
import 'package:injectable/injectable.dart';
34
import 'package:on_time_front/domain/entities/token_entity.dart';
@@ -18,10 +19,17 @@ class TokenLocalDataSourceImpl implements TokenLocalDataSource {
1819
accessibility: KeychainAccessibility.first_unlock_this_device,
1920
);
2021

21-
final storage = const FlutterSecureStorage(iOptions: _appleOptions);
22+
TokenLocalDataSourceImpl()
23+
: storage = const FlutterSecureStorage(iOptions: _appleOptions);
24+
25+
@visibleForTesting
26+
TokenLocalDataSourceImpl.withStorage(this.storage);
27+
28+
final FlutterSecureStorage storage;
2229

2330
final accessTokenKey = 'accessToken';
2431
final refreshTokenKey = 'refreshToken';
32+
TokenEntity? _cachedToken;
2533

2634
@override
2735
Future<void> storeTokens(TokenEntity token) async {
@@ -35,12 +43,19 @@ class TokenLocalDataSourceImpl implements TokenLocalDataSource {
3543
value: token.refreshToken,
3644
iOptions: _appleOptions,
3745
);
46+
_cachedToken = token;
3847
}
3948

4049
@override
4150
Future<TokenEntity> getToken() async {
51+
final cachedToken = _cachedToken;
52+
if (cachedToken != null) {
53+
return cachedToken;
54+
}
55+
4256
final token = await _readToken(_appleOptions);
4357
if (token != null) {
58+
_cachedToken = token;
4459
return token;
4560
}
4661

@@ -65,6 +80,7 @@ class TokenLocalDataSourceImpl implements TokenLocalDataSource {
6580
key: refreshTokenKey,
6681
iOptions: IOSOptions.defaultOptions,
6782
);
83+
_cachedToken = null;
6884
}
6985

7086
@override
@@ -74,6 +90,16 @@ class TokenLocalDataSourceImpl implements TokenLocalDataSource {
7490
value: token,
7591
iOptions: _appleOptions,
7692
);
93+
final cachedToken = _cachedToken;
94+
final refreshToken =
95+
cachedToken?.refreshToken ??
96+
await storage.read(key: refreshTokenKey, iOptions: _appleOptions);
97+
if (refreshToken != null) {
98+
_cachedToken = TokenEntity(
99+
accessToken: token,
100+
refreshToken: refreshToken,
101+
);
102+
}
77103
}
78104

79105
Future<TokenEntity?> _readToken(IOSOptions options) async {

test/core/dio/interceptors/token_interceptor_test.dart

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:async';
22
import 'dart:typed_data';
33

44
import 'package:dio/dio.dart';
5+
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
56
import 'package:flutter_test/flutter_test.dart';
67
import 'package:get_it/get_it.dart';
78
import 'package:google_sign_in/google_sign_in.dart';
@@ -42,6 +43,43 @@ void main() {
4243
await getIt.reset();
4344
});
4445

46+
test('reuses cached access token for repeated protected requests', () async {
47+
await getIt.reset();
48+
49+
final storage = _CountingSecureStorage({
50+
'accessToken': 'access-token',
51+
'refreshToken': 'refresh-token',
52+
});
53+
final cachedTokenLocalDataSource = TokenLocalDataSourceImpl.withStorage(
54+
storage,
55+
);
56+
getIt.registerSingleton<TokenLocalDataSource>(cachedTokenLocalDataSource);
57+
getIt.registerSingleton<UserRepository>(
58+
_FakeUserRepository(cachedTokenLocalDataSource),
59+
);
60+
61+
adapter = _TokenRefreshAdapter(authorizeProtectedRequests: true);
62+
dio = Dio(
63+
BaseOptions(
64+
baseUrl: 'https://example.com',
65+
receiveDataWhenStatusError: true,
66+
),
67+
)..httpClientAdapter = adapter;
68+
dio.interceptors.add(TokenInterceptor(dio));
69+
70+
final firstResponse = await dio.get<String>('/protected/one');
71+
final secondResponse = await dio.get<String>('/protected/two');
72+
73+
expect(firstResponse.statusCode, 200);
74+
expect(secondResponse.statusCode, 200);
75+
expect(adapter.refreshRequests, 0);
76+
expect(adapter.protectedAuthorizationHeaders, [
77+
'Bearer access-token',
78+
'Bearer access-token',
79+
]);
80+
expect(storage.readsByKey, {'accessToken': 1, 'refreshToken': 1});
81+
});
82+
4583
test(
4684
'stores refreshed access and refresh tokens before retrying request',
4785
() async {
@@ -190,12 +228,14 @@ class _TokenRefreshAdapter implements HttpClientAdapter {
190228
this.retryStatusCode = 200,
191229
this.refreshCompleter,
192230
this.omitRefreshHeaders = false,
231+
this.authorizeProtectedRequests = false,
193232
});
194233

195234
final int refreshStatusCode;
196235
final int retryStatusCode;
197236
final Completer<void>? refreshCompleter;
198237
final bool omitRefreshHeaders;
238+
final bool authorizeProtectedRequests;
199239

200240
final requestedPaths = <String>[];
201241
final protectedAuthorizationHeaders = <String?>[];
@@ -237,7 +277,7 @@ class _TokenRefreshAdapter implements HttpClientAdapter {
237277
final requestCount = (_pathRequestCounts[options.path] ?? 0) + 1;
238278
_pathRequestCounts[options.path] = requestCount;
239279

240-
if (requestCount == 1) {
280+
if (!authorizeProtectedRequests && requestCount == 1) {
241281
return _response(401, '{"message":"Unauthorized"}');
242282
}
243283

@@ -261,6 +301,27 @@ class _TokenRefreshAdapter implements HttpClientAdapter {
261301
void close({bool force = false}) {}
262302
}
263303

304+
class _CountingSecureStorage extends FlutterSecureStorage {
305+
_CountingSecureStorage(this.values);
306+
307+
final Map<String, String?> values;
308+
final readsByKey = <String, int>{};
309+
310+
@override
311+
Future<String?> read({
312+
required String key,
313+
AppleOptions? iOptions,
314+
AndroidOptions? aOptions,
315+
LinuxOptions? lOptions,
316+
WebOptions? webOptions,
317+
AppleOptions? mOptions,
318+
WindowsOptions? wOptions,
319+
}) async {
320+
readsByKey[key] = (readsByKey[key] ?? 0) + 1;
321+
return values[key];
322+
}
323+
}
324+
264325
class _FakeTokenLocalDataSource implements TokenLocalDataSource {
265326
TokenEntity token = const TokenEntity(
266327
accessToken: 'access-token',

test/data/data_sources/token_local_data_source_test.dart

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ void main() {
2222
expect(await dataSource.getToken(), token);
2323
});
2424

25+
test(
26+
'does not reread secure storage after the token cache is warm',
27+
() async {
28+
const token = TokenEntity(
29+
accessToken: 'access-token',
30+
refreshToken: 'refresh-token',
31+
);
32+
final storage = _CountingSecureStorage({
33+
'accessToken': token.accessToken,
34+
'refreshToken': token.refreshToken,
35+
});
36+
dataSource = TokenLocalDataSourceImpl.withStorage(storage);
37+
38+
expect(await dataSource.getToken(), token);
39+
expect(await dataSource.getToken(), token);
40+
41+
expect(storage.readsByKey, {'accessToken': 1, 'refreshToken': 1});
42+
},
43+
);
44+
2545
test('auth token write only updates the access token slot', () async {
2646
await dataSource.storeTokens(
2747
const TokenEntity(
@@ -41,6 +61,54 @@ void main() {
4161
);
4262
});
4363

64+
test(
65+
'auth token write warms the cache when refresh token is persisted',
66+
() async {
67+
final storage = _CountingSecureStorage({'refreshToken': 'refresh-token'});
68+
dataSource = TokenLocalDataSourceImpl.withStorage(storage);
69+
70+
await dataSource.storeAuthToken('new-access');
71+
storage.readsByKey.clear();
72+
73+
expect(
74+
await dataSource.getToken(),
75+
const TokenEntity(
76+
accessToken: 'new-access',
77+
refreshToken: 'refresh-token',
78+
),
79+
);
80+
expect(storage.readsByKey, isEmpty);
81+
},
82+
);
83+
84+
test(
85+
'legacy token load migrates to current storage and warms cache',
86+
() async {
87+
const legacyToken = TokenEntity(
88+
accessToken: 'legacy-access',
89+
refreshToken: 'legacy-refresh',
90+
);
91+
final storage = _LegacyTokenSecureStorage(
92+
currentValues: {},
93+
legacyValues: {
94+
'accessToken': legacyToken.accessToken,
95+
'refreshToken': legacyToken.refreshToken,
96+
},
97+
);
98+
dataSource = TokenLocalDataSourceImpl.withStorage(storage);
99+
100+
expect(await dataSource.getToken(), legacyToken);
101+
storage.readsByKey.clear();
102+
103+
expect(await dataSource.getToken(), legacyToken);
104+
expect(storage.currentValues, {
105+
'accessToken': legacyToken.accessToken,
106+
'refreshToken': legacyToken.refreshToken,
107+
});
108+
expect(storage.readsByKey, isEmpty);
109+
},
110+
);
111+
44112
test(
45113
'delete removes both token values and missing tokens fail clearly',
46114
() async {
@@ -66,3 +134,97 @@ void main() {
66134
},
67135
);
68136
}
137+
138+
class _CountingSecureStorage extends FlutterSecureStorage {
139+
_CountingSecureStorage(this.values);
140+
141+
final Map<String, String?> values;
142+
final readsByKey = <String, int>{};
143+
144+
@override
145+
Future<String?> read({
146+
required String key,
147+
AppleOptions? iOptions,
148+
AndroidOptions? aOptions,
149+
LinuxOptions? lOptions,
150+
WebOptions? webOptions,
151+
AppleOptions? mOptions,
152+
WindowsOptions? wOptions,
153+
}) async {
154+
readsByKey[key] = (readsByKey[key] ?? 0) + 1;
155+
return values[key];
156+
}
157+
158+
@override
159+
Future<void> write({
160+
required String key,
161+
required String? value,
162+
AppleOptions? iOptions,
163+
AndroidOptions? aOptions,
164+
LinuxOptions? lOptions,
165+
WebOptions? webOptions,
166+
AppleOptions? mOptions,
167+
WindowsOptions? wOptions,
168+
}) async {
169+
values[key] = value;
170+
}
171+
172+
@override
173+
Future<void> delete({
174+
required String key,
175+
AppleOptions? iOptions,
176+
AndroidOptions? aOptions,
177+
LinuxOptions? lOptions,
178+
WebOptions? webOptions,
179+
AppleOptions? mOptions,
180+
WindowsOptions? wOptions,
181+
}) async {
182+
values.remove(key);
183+
}
184+
}
185+
186+
class _LegacyTokenSecureStorage extends FlutterSecureStorage {
187+
_LegacyTokenSecureStorage({
188+
required this.currentValues,
189+
required this.legacyValues,
190+
});
191+
192+
final Map<String, String?> currentValues;
193+
final Map<String, String?> legacyValues;
194+
final readsByKey = <String, int>{};
195+
196+
@override
197+
Future<String?> read({
198+
required String key,
199+
AppleOptions? iOptions,
200+
AndroidOptions? aOptions,
201+
LinuxOptions? lOptions,
202+
WebOptions? webOptions,
203+
AppleOptions? mOptions,
204+
WindowsOptions? wOptions,
205+
}) async {
206+
readsByKey[key] = (readsByKey[key] ?? 0) + 1;
207+
return _valuesFor(iOptions)[key];
208+
}
209+
210+
@override
211+
Future<void> write({
212+
required String key,
213+
required String? value,
214+
AppleOptions? iOptions,
215+
AndroidOptions? aOptions,
216+
LinuxOptions? lOptions,
217+
WebOptions? webOptions,
218+
AppleOptions? mOptions,
219+
WindowsOptions? wOptions,
220+
}) async {
221+
_valuesFor(iOptions)[key] = value;
222+
}
223+
224+
Map<String, String?> _valuesFor(AppleOptions? iOptions) {
225+
return iOptions?.accessibility ==
226+
KeychainAccessibility.first_unlock_this_device
227+
? currentValues
228+
: legacyValues;
229+
}
230+
}

0 commit comments

Comments
 (0)