+4
-1
lib/src/features/auth/application/auth_providers.dart
+4
-1
lib/src/features/auth/application/auth_providers.dart
+53
lib/src/infrastructure/auth/auth_repository.dart
+53
lib/src/infrastructure/auth/auth_repository.dart
···
166
166
);
167
167
168
168
_logger.debug('Token exchange successful');
169
+
_validateScopes(tokenResponse.scope, OAuthClient.kScope);
170
+
_validateTokenClaims(tokenResponse.accessToken, did, pdsUrl);
169
171
170
172
final session = Session(
171
173
did: did,
···
203
205
key: dpopKey,
204
206
nonce: nonce,
205
207
);
208
+
209
+
_validateScopes(tokenResponse.scope, OAuthClient.kScope);
210
+
_validateTokenClaims(tokenResponse.accessToken, session.did, session.pdsUrl);
206
211
207
212
final newSession = session.copyWith(
208
213
accessJwt: tokenResponse.accessToken,
···
262
267
}
263
268
} catch (e) {
264
269
await _secureStorage.delete(key: _keyPendingSession);
270
+
}
271
+
}
272
+
273
+
/// Validates that granted scopes match requested scopes.
274
+
///
275
+
/// Logs a warning if the server returned fewer scopes than requested.
276
+
void _validateScopes(String? grantedScope, String requestedScope) {
277
+
if (grantedScope == null || grantedScope.isEmpty) {
278
+
_logger.warning('No scopes granted by server. Requested: $requestedScope');
279
+
return;
280
+
}
281
+
282
+
final grantedScopes = grantedScope.split(' ').toSet();
283
+
final requestedScopes = requestedScope.split(' ').toSet();
284
+
285
+
final missingScopes = requestedScopes.difference(grantedScopes);
286
+
if (missingScopes.isNotEmpty) {
287
+
_logger.warning(
288
+
'Server granted reduced scopes. Requested: $requestedScope, Granted: $grantedScope, Missing: ${missingScopes.join(' ')}',
289
+
);
290
+
}
291
+
}
292
+
293
+
/// Validates JWT claims (sub and iss) match expected values.
294
+
///
295
+
/// Decodes the access token and verifies:
296
+
/// - `sub` claim matches the expected DID
297
+
/// - `iss` claim matches the expected PDS URL
298
+
void _validateTokenClaims(String accessToken, String expectedDid, String expectedPdsUrl) {
299
+
try {
300
+
final jwt = JsonWebToken.unverified(accessToken);
301
+
final claims = jwt.claims;
302
+
303
+
final sub = claims.getTyped<String>('sub');
304
+
if (sub != expectedDid) {
305
+
_logger.warning(
306
+
'Token sub claim mismatch. Expected: $expectedDid, Got: $sub',
307
+
);
308
+
}
309
+
310
+
final iss = claims.getTyped<String>('iss');
311
+
if (iss != null && iss != expectedPdsUrl) {
312
+
_logger.warning(
313
+
'Token iss claim mismatch. Expected: $expectedPdsUrl, Got: $iss',
314
+
);
315
+
}
316
+
} catch (e) {
317
+
_logger.warning('Failed to validate token claims: $e');
265
318
}
266
319
}
267
320
+29
-4
lib/src/infrastructure/auth/oauth_client.dart
+29
-4
lib/src/infrastructure/auth/oauth_client.dart
···
1
1
import 'package:dio/dio.dart';
2
2
import 'package:jose/jose.dart';
3
3
4
+
import '../../core/utils/logger.dart';
4
5
import 'dpop_utils.dart';
5
6
import 'oauth_exceptions.dart';
6
7
import 'server_metadata.dart';
7
8
8
9
class OAuthClient {
9
-
OAuthClient({required Dio dio}) : _dio = dio;
10
+
OAuthClient({required Dio dio, Logger? logger})
11
+
: _dio = dio,
12
+
_logger = logger ?? const Logger('OAuthClient');
10
13
11
14
final Dio _dio;
15
+
final Logger _logger;
12
16
13
17
static const kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json';
14
18
static const kRedirectUri = 'org.stormlightlabs.lazurite://callback';
···
61
65
}
62
66
63
67
final data = response.data!;
64
-
return data['request_uri'] as String;
68
+
final requestUri = data['request_uri'] as String?;
69
+
70
+
if (requestUri == null || requestUri.isEmpty) {
71
+
throw Exception('PAR response missing or empty request_uri');
72
+
}
73
+
74
+
// Validate request_uri format (should start with 'urn:ietf:params:oauth:request_uri:')
75
+
if (!requestUri.startsWith('urn:ietf:params:oauth:request_uri:')) {
76
+
throw Exception('Invalid request_uri format: $requestUri');
77
+
}
78
+
79
+
final expiresIn = data['expires_in'] as int?;
80
+
if (expiresIn == null) {
81
+
throw Exception('PAR response missing expires_in');
82
+
}
83
+
84
+
// Warn if expires_in is too short (less than 30 seconds)
85
+
if (expiresIn < 30) {
86
+
_logger.warning('PAR expires_in is very short: $expiresIn seconds');
87
+
}
88
+
89
+
return requestUri;
65
90
} on DioException catch (e) {
66
91
if (e.response?.data != null && e.response!.data is Map<String, dynamic>) {
67
92
throw OAuthException.fromJson(e.response!.data as Map<String, dynamic>);
···
196
221
);
197
222
} on DioException catch (e) {
198
223
if (e.response?.data != null && e.response!.data is Map<String, dynamic>) {
199
-
// TODO: Log error for debugging: $error
200
-
OAuthException.fromJson(e.response!.data as Map<String, dynamic>);
224
+
final error = OAuthException.fromJson(e.response!.data as Map<String, dynamic>);
225
+
_logger.warning('Token revocation failed', error);
201
226
}
202
227
}
203
228
}
+83
-43
lib/src/infrastructure/network/interceptors/auth_interceptor.dart
+83
-43
lib/src/infrastructure/network/interceptors/auth_interceptor.dart
···
1
+
import 'dart:async';
2
+
1
3
import 'package:dio/dio.dart';
2
4
import 'package:jose/jose.dart';
3
5
import 'package:lazurite/src/core/auth/session_model.dart';
6
+
import 'package:lazurite/src/core/utils/logger.dart';
4
7
import 'package:lazurite/src/infrastructure/auth/dpop_nonce_store.dart';
5
8
import 'package:lazurite/src/infrastructure/auth/dpop_utils.dart';
6
9
···
19
22
required this.getSession,
20
23
required this.refreshSession,
21
24
DPoPNonceStore? nonceStore,
22
-
}) : _nonceStore = nonceStore ?? DPoPNonceStore();
25
+
Logger? logger,
26
+
}) : _nonceStore = nonceStore ?? DPoPNonceStore(),
27
+
_logger = logger ?? const Logger('AuthInterceptor');
23
28
24
29
/// Callback to get the current session.
25
30
final SessionGetter getSession;
···
30
35
/// Store for DPoP nonces.
31
36
final DPoPNonceStore _nonceStore;
32
37
33
-
/// Lock to prevent concurrent refresh attempts.
34
-
bool _isRefreshing = false;
38
+
/// Logger instance.
39
+
final Logger _logger;
40
+
41
+
/// Completer used to queue requests during token refresh.
42
+
///
43
+
/// When null, no refresh is in progress. When non-null, a refresh is in progress
44
+
/// and concurrent requests should wait for the completer to complete.
45
+
Completer<Session?>? _refreshCompleter;
35
46
36
47
/// Key used to mark requests as requiring auth in options.extra.
37
48
static const requiresAuthKey = 'requiresAuth';
49
+
50
+
/// Retries a request with a new session.
51
+
Future<void> _retryRequestWithSession(
52
+
DioException err,
53
+
Session newSession,
54
+
ErrorInterceptorHandler handler,
55
+
) async {
56
+
final options = err.requestOptions;
57
+
options.headers['Authorization'] = 'Bearer ${newSession.accessJwt}';
58
+
59
+
try {
60
+
final dpopKey = JsonWebKey.fromJson(newSession.dpopKey);
61
+
final url = options.uri.toString();
62
+
final method = options.method;
63
+
final nonce = _nonceStore.get(newSession.pdsUrl);
64
+
65
+
final proof = await DPoPUtils.createProof(
66
+
url: url,
67
+
method: method,
68
+
privateKey: dpopKey,
69
+
accessToken: newSession.accessJwt,
70
+
nonce: nonce,
71
+
);
72
+
73
+
options.headers['DPoP'] = proof;
74
+
} catch (e) {
75
+
_logger.warning('Failed to create DPoP proof for retry', e);
76
+
}
77
+
78
+
options.extra['_authRetried'] = true;
79
+
final retryDio = Dio(
80
+
BaseOptions(
81
+
baseUrl: options.baseUrl,
82
+
connectTimeout: options.connectTimeout,
83
+
receiveTimeout: options.receiveTimeout,
84
+
sendTimeout: options.sendTimeout,
85
+
),
86
+
);
87
+
88
+
try {
89
+
final response = await retryDio.fetch(options);
90
+
handler.resolve(response);
91
+
} catch (e) {
92
+
handler.next(err);
93
+
}
94
+
}
38
95
39
96
@override
40
97
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
···
62
119
63
120
options.headers['DPoP'] = proof;
64
121
} catch (e) {
65
-
// TODO: Log error for debugging: $e
122
+
_logger.warning('Failed to create DPoP proof for request', e);
66
123
}
67
124
}
68
125
}
···
107
164
return handler.next(err);
108
165
}
109
166
110
-
if (_isRefreshing) {
111
-
return handler.next(err);
112
-
}
113
167
final hasRetried = err.requestOptions.extra['_authRetried'] == true;
114
168
if (hasRetried) {
115
169
return handler.next(err);
116
170
}
117
171
118
-
_isRefreshing = true;
172
+
// If a refresh is already in progress, wait for it to complete
173
+
if (_refreshCompleter != null) {
174
+
try {
175
+
final newSession = await _refreshCompleter!.future;
176
+
if (newSession == null) {
177
+
return handler.next(err);
178
+
}
179
+
180
+
// Retry with the new session
181
+
return _retryRequestWithSession(err, newSession, handler);
182
+
} catch (e) {
183
+
return handler.next(err);
184
+
}
185
+
}
186
+
187
+
// Start a new refresh
188
+
_refreshCompleter = Completer<Session?>();
119
189
120
190
try {
121
191
final newSession = await refreshSession();
192
+
_refreshCompleter!.complete(newSession);
122
193
123
194
if (newSession == null) {
124
195
return handler.next(err);
125
196
}
126
197
127
-
final options = err.requestOptions;
128
-
options.headers['Authorization'] = 'Bearer ${newSession.accessJwt}';
129
-
130
-
try {
131
-
final dpopKey = JsonWebKey.fromJson(newSession.dpopKey);
132
-
final url = options.uri.toString();
133
-
final method = options.method;
134
-
final nonce = _nonceStore.get(newSession.pdsUrl);
135
-
136
-
final proof = await DPoPUtils.createProof(
137
-
url: url,
138
-
method: method,
139
-
privateKey: dpopKey,
140
-
accessToken: newSession.accessJwt,
141
-
nonce: nonce,
142
-
);
143
-
144
-
options.headers['DPoP'] = proof;
145
-
} catch (e) {
146
-
// TODO: Log error for debugging: $e
147
-
}
148
-
149
-
options.extra['_authRetried'] = true;
150
-
final retryDio = Dio(
151
-
BaseOptions(
152
-
baseUrl: options.baseUrl,
153
-
connectTimeout: options.connectTimeout,
154
-
receiveTimeout: options.receiveTimeout,
155
-
sendTimeout: options.sendTimeout,
156
-
),
157
-
);
158
-
159
-
final response = await retryDio.fetch(options);
160
-
return handler.resolve(response);
198
+
// Retry the original request
199
+
return _retryRequestWithSession(err, newSession, handler);
161
200
} catch (e) {
201
+
_refreshCompleter!.completeError(e);
162
202
return handler.next(err);
163
203
} finally {
164
-
_isRefreshing = false;
204
+
_refreshCompleter = null;
165
205
}
166
206
}
167
207
}
+261
test/src/infrastructure/auth/auth_test.dart
+261
test/src/infrastructure/auth/auth_test.dart
···
97
97
});
98
98
});
99
99
100
+
group('OAuthClient', () {
101
+
late MockDio dio;
102
+
late MockLogger logger;
103
+
late OAuthClient client;
104
+
105
+
setUp(() {
106
+
dio = MockDio();
107
+
logger = MockLogger();
108
+
client = OAuthClient(dio: dio, logger: logger);
109
+
});
110
+
111
+
test('pushedAuthorizationRequest validates request_uri format', () async {
112
+
const metadata = ServerMetadata(
113
+
issuer: 'https://pds.com',
114
+
authorizationEndpoint: 'https://pds.com/oauth/authorize',
115
+
tokenEndpoint: 'https://pds.com/oauth/token',
116
+
pushedAuthorizationRequestEndpoint: 'https://pds.com/oauth/par',
117
+
);
118
+
119
+
when(() => dio.post<Map<String, dynamic>>(any(), options: any(named: 'options'), data: any(named: 'data')))
120
+
.thenAnswer(
121
+
(_) async => Response(
122
+
requestOptions: RequestOptions(path: ''),
123
+
statusCode: 201,
124
+
data: {
125
+
'request_uri': 'invalid-format',
126
+
'expires_in': 60,
127
+
},
128
+
),
129
+
);
130
+
131
+
final key = await DPoPUtils.generateKey();
132
+
133
+
await expectLater(
134
+
() => client.pushedAuthorizationRequest(
135
+
metadata: metadata,
136
+
key: key,
137
+
state: 'state',
138
+
codeChallenge: 'challenge',
139
+
),
140
+
throwsA(isA<Exception>().having(
141
+
(e) => e.toString(),
142
+
'message',
143
+
contains('Invalid request_uri format'),
144
+
)),
145
+
);
146
+
});
147
+
148
+
test('pushedAuthorizationRequest throws on missing expires_in', () async {
149
+
const metadata = ServerMetadata(
150
+
issuer: 'https://pds.com',
151
+
authorizationEndpoint: 'https://pds.com/oauth/authorize',
152
+
tokenEndpoint: 'https://pds.com/oauth/token',
153
+
pushedAuthorizationRequestEndpoint: 'https://pds.com/oauth/par',
154
+
);
155
+
156
+
when(() => dio.post<Map<String, dynamic>>(any(), options: any(named: 'options'), data: any(named: 'data')))
157
+
.thenAnswer(
158
+
(_) async => Response(
159
+
requestOptions: RequestOptions(path: ''),
160
+
statusCode: 201,
161
+
data: {
162
+
'request_uri': 'urn:ietf:params:oauth:request_uri:test',
163
+
},
164
+
),
165
+
);
166
+
167
+
final key = await DPoPUtils.generateKey();
168
+
169
+
await expectLater(
170
+
() => client.pushedAuthorizationRequest(
171
+
metadata: metadata,
172
+
key: key,
173
+
state: 'state',
174
+
codeChallenge: 'challenge',
175
+
),
176
+
throwsA(isA<Exception>().having(
177
+
(e) => e.toString(),
178
+
'message',
179
+
contains('PAR response missing expires_in'),
180
+
)),
181
+
);
182
+
});
183
+
184
+
test('pushedAuthorizationRequest logs warning for short expires_in', () async {
185
+
const metadata = ServerMetadata(
186
+
issuer: 'https://pds.com',
187
+
authorizationEndpoint: 'https://pds.com/oauth/authorize',
188
+
tokenEndpoint: 'https://pds.com/oauth/token',
189
+
pushedAuthorizationRequestEndpoint: 'https://pds.com/oauth/par',
190
+
);
191
+
192
+
when(() => dio.post<Map<String, dynamic>>(any(), options: any(named: 'options'), data: any(named: 'data')))
193
+
.thenAnswer(
194
+
(_) async => Response(
195
+
requestOptions: RequestOptions(path: ''),
196
+
statusCode: 201,
197
+
data: {
198
+
'request_uri': 'urn:ietf:params:oauth:request_uri:test',
199
+
'expires_in': 15, // Less than 30 seconds
200
+
},
201
+
),
202
+
);
203
+
when(() => logger.warning(any())).thenReturn(null);
204
+
205
+
final key = await DPoPUtils.generateKey();
206
+
207
+
final requestUri = await client.pushedAuthorizationRequest(
208
+
metadata: metadata,
209
+
key: key,
210
+
state: 'state',
211
+
codeChallenge: 'challenge',
212
+
);
213
+
214
+
expect(requestUri, 'urn:ietf:params:oauth:request_uri:test');
215
+
verify(() => logger.warning(any(that: contains('very short')))).called(1);
216
+
});
217
+
218
+
test('pushedAuthorizationRequest succeeds with valid response', () async {
219
+
const metadata = ServerMetadata(
220
+
issuer: 'https://pds.com',
221
+
authorizationEndpoint: 'https://pds.com/oauth/authorize',
222
+
tokenEndpoint: 'https://pds.com/oauth/token',
223
+
pushedAuthorizationRequestEndpoint: 'https://pds.com/oauth/par',
224
+
);
225
+
226
+
when(() => dio.post<Map<String, dynamic>>(any(), options: any(named: 'options'), data: any(named: 'data')))
227
+
.thenAnswer(
228
+
(_) async => Response(
229
+
requestOptions: RequestOptions(path: ''),
230
+
statusCode: 201,
231
+
data: {
232
+
'request_uri': 'urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY',
233
+
'expires_in': 60,
234
+
},
235
+
),
236
+
);
237
+
238
+
final key = await DPoPUtils.generateKey();
239
+
240
+
final requestUri = await client.pushedAuthorizationRequest(
241
+
metadata: metadata,
242
+
key: key,
243
+
state: 'state',
244
+
codeChallenge: 'challenge',
245
+
);
246
+
247
+
expect(requestUri, 'urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY');
248
+
});
249
+
});
250
+
100
251
group('DPoPUtils', () {
101
252
test('generateKey returns ES256 key', () async {
102
253
final key = await DPoPUtils.generateKey();
···
269
420
),
270
421
).called(1);
271
422
verify(() => sessionStorage.saveSession(any())).called(1);
423
+
});
424
+
425
+
test('completeLogin validates scopes and logs warning on mismatch', () async {
426
+
final uri = Uri.parse('org.stormlightlabs.lazurite://callback?code=abc&state=xyz');
427
+
final key = await DPoPUtils.generateKey();
428
+
final pendingState = {
429
+
'did': 'did:plc:user',
430
+
'handle': 'user.bsky.social',
431
+
'pdsUrl': 'https://pds.com',
432
+
'verifier': 'verifier123',
433
+
'state': 'xyz',
434
+
'dpopKey': key.toJson(),
435
+
};
436
+
437
+
const testMetadata = ServerMetadata(
438
+
issuer: 'https://pds.com',
439
+
authorizationEndpoint: 'https://pds.com/oauth/authorize',
440
+
tokenEndpoint: 'https://pds.com/oauth/token',
441
+
);
442
+
443
+
when(() => secureStorage.read(key: any(named: 'key')))
444
+
.thenAnswer((_) async => jsonEncode(pendingState));
445
+
when(() => secureStorage.delete(key: any(named: 'key'))).thenAnswer((_) async {});
446
+
when(() => metadataRepo.discover(any())).thenAnswer((_) async => testMetadata);
447
+
when(
448
+
() => oauthClient.exchangeCodeForToken(
449
+
metadata: any(named: 'metadata'),
450
+
code: any(named: 'code'),
451
+
codeVerifier: any(named: 'codeVerifier'),
452
+
key: any(named: 'key'),
453
+
nonce: any(named: 'nonce'),
454
+
),
455
+
).thenAnswer(
456
+
(_) async => TokenResponse(
457
+
accessToken: 'access',
458
+
tokenType: 'Bearer',
459
+
refreshToken: 'refresh',
460
+
scope: 'atproto', // Missing 'transition:generic'
461
+
expiresIn: 3600,
462
+
),
463
+
);
464
+
when(() => sessionStorage.saveSession(any())).thenAnswer((_) async {});
465
+
when(() => logger.warning(any())).thenReturn(null);
466
+
467
+
await authRepo.completeLogin(uri);
468
+
469
+
// Verify warning was logged for reduced scopes
470
+
verify(() => logger.warning(any(that: contains('reduced scopes')))).called(1);
471
+
});
472
+
473
+
test('completeLogin validates JWT claims and logs warning on mismatch', () async {
474
+
final uri = Uri.parse('org.stormlightlabs.lazurite://callback?code=abc&state=xyz');
475
+
final key = await DPoPUtils.generateKey();
476
+
477
+
// Create a JWT with mismatched sub claim
478
+
final jwtKey = await DPoPUtils.generateKey();
479
+
final builder = JsonWebSignatureBuilder()
480
+
..jsonContent = {
481
+
'sub': 'did:plc:wrong-user',
482
+
'iss': 'https://pds.com',
483
+
'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
484
+
'exp': DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000,
485
+
}
486
+
..addRecipient(jwtKey, algorithm: 'ES256');
487
+
final jws = builder.build();
488
+
final accessToken = jws.toCompactSerialization();
489
+
490
+
final pendingState = {
491
+
'did': 'did:plc:user',
492
+
'handle': 'user.bsky.social',
493
+
'pdsUrl': 'https://pds.com',
494
+
'verifier': 'verifier123',
495
+
'state': 'xyz',
496
+
'dpopKey': key.toJson(),
497
+
};
498
+
499
+
const testMetadata = ServerMetadata(
500
+
issuer: 'https://pds.com',
501
+
authorizationEndpoint: 'https://pds.com/oauth/authorize',
502
+
tokenEndpoint: 'https://pds.com/oauth/token',
503
+
);
504
+
505
+
when(() => secureStorage.read(key: any(named: 'key')))
506
+
.thenAnswer((_) async => jsonEncode(pendingState));
507
+
when(() => secureStorage.delete(key: any(named: 'key'))).thenAnswer((_) async {});
508
+
when(() => metadataRepo.discover(any())).thenAnswer((_) async => testMetadata);
509
+
when(
510
+
() => oauthClient.exchangeCodeForToken(
511
+
metadata: any(named: 'metadata'),
512
+
code: any(named: 'code'),
513
+
codeVerifier: any(named: 'codeVerifier'),
514
+
key: any(named: 'key'),
515
+
nonce: any(named: 'nonce'),
516
+
),
517
+
).thenAnswer(
518
+
(_) async => TokenResponse(
519
+
accessToken: accessToken,
520
+
tokenType: 'Bearer',
521
+
refreshToken: 'refresh',
522
+
scope: 'atproto transition:generic',
523
+
expiresIn: 3600,
524
+
),
525
+
);
526
+
when(() => sessionStorage.saveSession(any())).thenAnswer((_) async {});
527
+
when(() => logger.warning(any(), any())).thenReturn(null);
528
+
529
+
await authRepo.completeLogin(uri);
530
+
531
+
// Verify warning was logged for sub claim mismatch
532
+
verify(() => logger.warning(any(that: contains('sub claim mismatch')), any())).called(1);
272
533
});
273
534
274
535
test('loginWithAppPassword failure', () async {
+37
test/src/infrastructure/network/interceptors/auth_interceptor_test.dart
+37
test/src/infrastructure/network/interceptors/auth_interceptor_test.dart
···
219
219
expect(refreshCount, lessThanOrEqualTo(1));
220
220
});
221
221
222
+
test('queues concurrent 401 requests and retries after single refresh', () async {
223
+
final dio = Dio(BaseOptions(baseUrl: 'https://test.api'));
224
+
final adapter = DioAdapter(dio: dio);
225
+
226
+
var refreshCount = 0;
227
+
228
+
dio.interceptors.add(
229
+
AuthInterceptor(
230
+
getSession: () async => _createTestSession(),
231
+
refreshSession: () async {
232
+
refreshCount++;
233
+
// Simulate slow refresh
234
+
await Future.delayed(const Duration(milliseconds: 100));
235
+
return _createTestSession(accessJwt: 'refreshed-token');
236
+
},
237
+
),
238
+
);
239
+
240
+
adapter.onGet('/test1', (server) => server.reply(401, {'error': 'Unauthorized'}));
241
+
adapter.onGet('/test2', (server) => server.reply(401, {'error': 'Unauthorized'}));
242
+
243
+
// Fire two concurrent requests that will both get 401
244
+
final futures = [
245
+
dio.get('/test1', options: Options(extra: {AuthInterceptor.requiresAuthKey: true})),
246
+
dio.get('/test2', options: Options(extra: {AuthInterceptor.requiresAuthKey: true})),
247
+
];
248
+
249
+
try {
250
+
await Future.wait(futures);
251
+
} catch (e) {
252
+
// Expected to fail since mock adapter can't handle retries properly
253
+
}
254
+
255
+
// Both requests should trigger only ONE refresh due to queueing
256
+
expect(refreshCount, equals(1));
257
+
});
258
+
222
259
test('does not attempt refresh for non-auth requests', () async {
223
260
final dio = Dio(BaseOptions(baseUrl: 'https://test.api'));
224
261
final adapter = DioAdapter(dio: dio);