mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter

feat: add PAR and post-login validation,

* improve auth interceptor's concurrent token refresh handling

Changed files
+469 -48
.github
lib
src
features
auth
application
infrastructure
test
src
infrastructure
auth
network
+2
.github/FUNDING.yml
··· 1 + github: [desertthunder] 2 + ko_fi: desertthunder
+4 -1
lib/src/features/auth/application/auth_providers.dart
··· 30 30 31 31 @Riverpod(keepAlive: true) 32 32 OAuthClient oauthClient(Ref ref) { 33 - return OAuthClient(dio: Dio()); 33 + return OAuthClient( 34 + dio: Dio(), 35 + logger: ref.watch(loggerProvider('OAuthClient')), 36 + ); 34 37 } 35 38 36 39 @Riverpod(keepAlive: true)
+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
··· 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
··· 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
··· 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
··· 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);