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

Configure Feed

Select the types of activity you want to include in your feed.

fix: make failed refresh non-destructive (#23)

* fix: make failed refresh non-destructive

* build: add tests for invalidation error paths

authored by

Owais and committed by
GitHub
ccf34790 5dd35ca1

+363 -8
+133 -8
lib/features/auth/data/auth_repository.dart
··· 28 28 required String service, 29 29 required OAuthSession session, 30 30 }); 31 + typedef AppPasswordRefreshSession = 32 + Future<atcore.XRPCResponse<atcore.Session>> Function({required String refreshJwt, String? service}); 31 33 32 34 final class AuthIdentifierResolutionException implements Exception { 33 35 const AuthIdentifierResolutionException(this.message); ··· 38 40 String toString() => message; 39 41 } 40 42 43 + final class _SessionRefreshException implements Exception { 44 + const _SessionRefreshException(this.message, {required this.shouldInvalidateSession}); 45 + 46 + final String message; 47 + final bool shouldInvalidateSession; 48 + 49 + @override 50 + String toString() => message; 51 + } 52 + 53 + final class _OAuthRefreshAttemptFailure { 54 + const _OAuthRefreshAttemptFailure({required this.service, required this.oauthErrorCode}); 55 + 56 + final String service; 57 + final String? oauthErrorCode; 58 + } 59 + 41 60 class AuthRepository { 42 61 AuthRepository({ 43 62 required AppDatabase database, ··· 45 64 CloseInAppBrowser closeInAppBrowser = closeInAppWebView, 46 65 SupportsCloseForMode supportsCloseForMode = supportsCloseForLaunchMode, 47 66 OAuthRefreshSession oauthRefreshSession = _defaultOAuthRefreshSession, 67 + AppPasswordRefreshSession appPasswordRefreshSession = _defaultAppPasswordRefreshSession, 48 68 Future<OAuthClientMetadata> Function(String clientId) loadClientMetadata = getClientMetadata, 49 69 String Function()? oauthServiceResolver, 50 70 bool Function()? slingshotIdentityFallbackEnabledResolver, ··· 56 76 _closeInAppBrowser = closeInAppBrowser, 57 77 _supportsCloseForMode = supportsCloseForMode, 58 78 _oauthRefreshSession = oauthRefreshSession, 79 + _appPasswordRefreshSession = appPasswordRefreshSession, 59 80 _loadClientMetadata = loadClientMetadata, 60 81 _oauthServiceResolver = oauthServiceResolver ?? _defaultOAuthServiceResolver, 61 82 _slingshotIdentityFallbackEnabledResolver = slingshotIdentityFallbackEnabledResolver ?? _defaultFalse, ··· 86 107 final CloseInAppBrowser _closeInAppBrowser; 87 108 final SupportsCloseForMode _supportsCloseForMode; 88 109 final OAuthRefreshSession _oauthRefreshSession; 110 + final AppPasswordRefreshSession _appPasswordRefreshSession; 89 111 final Future<OAuthClientMetadata> Function(String clientId) _loadClientMetadata; 90 112 final String Function() _oauthServiceResolver; 91 113 final bool Function() _slingshotIdentityFallbackEnabledResolver; ··· 152 174 return await refreshSession(storedSession); 153 175 } catch (error, stackTrace) { 154 176 log.e('AuthRepository: Failed to restore expired session', error: error, stackTrace: stackTrace); 155 - return restoreSession(); 177 + final fallbackSession = await getStoredSession(); 178 + if (fallbackSession != null && fallbackSession.did == storedSession.did) { 179 + log.w( 180 + 'AuthRepository: Preserving expired stored session for ${storedSession.handle} after refresh failure; ' 181 + 'runtime auth recovery can retry without forcing sign-in.', 182 + ); 183 + } 184 + return fallbackSession; 156 185 } 157 186 } 158 187 ··· 351 380 publicKey: publicKey, 352 381 privateKey: privateKey, 353 382 ); 383 + final issuerHost = normalizeAtprotoServiceHost(restoredSession.accessTokenJwt.iss); 384 + final storedAuthHost = normalizeAtprotoServiceHost(currentSession.oauthService); 354 385 final oauthServices = _oauthRefreshServiceCandidates( 355 386 storedAuthService: currentSession.oauthService, 356 - issuer: restoredSession.accessTokenJwt.iss, 387 + issuer: issuerHost, 357 388 ); 358 389 359 390 Object? lastAttemptError; 360 391 StackTrace? lastAttemptStackTrace; 361 392 String? successfulOauthService; 362 393 final failedAttemptSummaries = <String>[]; 394 + final failedAttempts = <_OAuthRefreshAttemptFailure>[]; 363 395 OAuthSession? refreshedSession; 364 396 for (final oauthService in oauthServices) { 365 397 try { ··· 380 412 lastAttemptStackTrace = stackTrace; 381 413 final summary = _summarizeOAuthRefreshError(error); 382 414 failedAttemptSummaries.add('$oauthService=$summary'); 415 + failedAttempts.add( 416 + _OAuthRefreshAttemptFailure( 417 + service: oauthService, 418 + oauthErrorCode: error is OAuthException ? _oauthRefreshErrorCode(error.message) : null, 419 + ), 420 + ); 383 421 log.w( 384 422 'AuthRepository: OAuth refresh attempt failed using auth service $oauthService ($summary)', 385 423 error: error, ··· 389 427 } 390 428 391 429 if (refreshedSession == null) { 430 + final shouldInvalidateFailedOAuthRefresh = _shouldInvalidateOAuthSessionAfterRefreshFailures( 431 + failedAttempts, 432 + issuerHost: issuerHost, 433 + storedAuthHost: storedAuthHost, 434 + ); 392 435 Error.throwWithStackTrace( 393 - Exception( 436 + _SessionRefreshException( 394 437 'OAuth refresh failed across ${oauthServices.length} auth service candidate(s). ' 395 438 'Attempts: ${failedAttemptSummaries.join(' | ')}. Last error: $lastAttemptError', 439 + shouldInvalidateSession: shouldInvalidateFailedOAuthRefresh, 396 440 ), 397 441 lastAttemptStackTrace ?? StackTrace.current, 398 442 ); ··· 417 461 ); 418 462 return refreshedTokens; 419 463 } catch (error, stackTrace) { 420 - log.e('AuthRepository: OAuth session refresh failed', error: error, stackTrace: stackTrace); 421 - await _invalidateSession(currentSession); 464 + final shouldInvalidate = _shouldInvalidateSessionAfterRefreshFailure(error); 465 + log.e( 466 + 'AuthRepository: OAuth session refresh failed; ' 467 + '${shouldInvalidate ? 'invalidating rejected credentials' : 'preserving session for retry'}', 468 + error: error, 469 + stackTrace: stackTrace, 470 + ); 471 + if (shouldInvalidate) { 472 + await _invalidateSession(currentSession); 473 + } 422 474 throw Exception('Failed to refresh OAuth session: $error'); 423 475 } 424 476 } 425 477 426 478 try { 427 479 log.i('AuthRepository: Refreshing app password session for ${currentSession.handle}'); 428 - final refreshed = await atp.refreshSession( 480 + final refreshed = await _appPasswordRefreshSession( 429 481 refreshJwt: currentSession.refreshToken!, 430 482 service: currentSession.service, 431 483 ); ··· 448 500 log.i('AuthRepository: App password session refresh succeeded for ${tokens.handle}'); 449 501 return tokens; 450 502 } catch (error, stackTrace) { 451 - log.e('AuthRepository: App password session refresh failed', error: error, stackTrace: stackTrace); 452 - await _invalidateSession(currentSession); 503 + final shouldInvalidate = _shouldInvalidateSessionAfterRefreshFailure(error); 504 + log.e( 505 + 'AuthRepository: App password session refresh failed; ' 506 + '${shouldInvalidate ? 'invalidating rejected credentials' : 'preserving session for retry'}', 507 + error: error, 508 + stackTrace: stackTrace, 509 + ); 510 + if (shouldInvalidate) { 511 + await _invalidateSession(currentSession); 512 + } 453 513 throw Exception('Failed to refresh session: $error'); 454 514 } 455 515 } ··· 1012 1072 } 1013 1073 } 1014 1074 1075 + bool _shouldInvalidateSessionAfterRefreshFailure(Object error) { 1076 + if (error is _SessionRefreshException) { 1077 + return error.shouldInvalidateSession; 1078 + } 1079 + 1080 + if (error is atcore.UnauthorizedException) { 1081 + return true; 1082 + } 1083 + 1084 + if (error is OAuthException) { 1085 + return _oauthRefreshErrorCode(error.message) == 'invalid_grant'; 1086 + } 1087 + 1088 + return false; 1089 + } 1090 + 1091 + String? _oauthRefreshErrorCode(String message) { 1092 + try { 1093 + final decoded = jsonDecode(message); 1094 + if (decoded is Map<String, dynamic>) { 1095 + final error = decoded['error']; 1096 + if (error is String && error.trim().isNotEmpty) { 1097 + return error.trim(); 1098 + } 1099 + } 1100 + } catch (error, stackTrace) { 1101 + log.d('AuthRepository: Unable to parse OAuth refresh error code', error: error, stackTrace: stackTrace); 1102 + } 1103 + 1104 + return null; 1105 + } 1106 + 1107 + bool _shouldInvalidateOAuthSessionAfterRefreshFailures( 1108 + List<_OAuthRefreshAttemptFailure> failures, { 1109 + required String? issuerHost, 1110 + required String? storedAuthHost, 1111 + }) { 1112 + if (failures.isEmpty) { 1113 + return false; 1114 + } 1115 + 1116 + final allCandidatesRejectedCredentials = failures.every((failure) => failure.oauthErrorCode == 'invalid_grant'); 1117 + if (allCandidatesRejectedCredentials) { 1118 + return true; 1119 + } 1120 + 1121 + final authoritativeHost = issuerHost ?? storedAuthHost; 1122 + if (authoritativeHost == null) { 1123 + return false; 1124 + } 1125 + 1126 + return failures.any( 1127 + (failure) => 1128 + normalizeAtprotoServiceHost(failure.service) == authoritativeHost && 1129 + failure.oauthErrorCode == 'invalid_grant', 1130 + ); 1131 + } 1132 + 1015 1133 void _resetPendingOAuthState({bool clearLaunchMode = true}) { 1016 1134 _oauthCompleter = null; 1017 1135 _resetPendingOAuthAttemptState(); ··· 1051 1169 }) { 1052 1170 final oauthClient = OAuthClient(metadata, service: service); 1053 1171 return oauthClient.refresh(session); 1172 + } 1173 + 1174 + static Future<atcore.XRPCResponse<atcore.Session>> _defaultAppPasswordRefreshSession({ 1175 + required String refreshJwt, 1176 + String? service, 1177 + }) { 1178 + return atp.refreshSession(refreshJwt: refreshJwt, service: service); 1054 1179 } 1055 1180 1056 1181 static String _defaultOAuthServiceResolver() {
+230
test/features/auth/data/auth_repository_test.dart
··· 163 163 expect(restored, isNotNull); 164 164 expect(restored!.handle, equals('user.bsky.social')); 165 165 }); 166 + 167 + test('preserves expired stored session when refresh failure is transient', () async { 168 + final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); 169 + final account = Account( 170 + did: 'did:plc:oauth123', 171 + handle: 'oauth-user.bsky.social', 172 + service: 'porcini.us-east.host.bsky.network', 173 + oauthService: 'bsky.social', 174 + oauthClientId: AuthRepository.kClientId, 175 + accessToken: 'expired-access-token', 176 + refreshToken: 'refresh-token', 177 + dpopPublicKey: 'public-key', 178 + dpopPrivateKey: 'private-key', 179 + dpopNonce: 'nonce', 180 + displayName: 'OAuth User', 181 + expiresAt: expiredAt, 182 + createdAt: DateTime.now(), 183 + updatedAt: DateTime.now(), 184 + ); 185 + 186 + authRepository = AuthRepository( 187 + database: mockDatabase, 188 + loadClientMetadata: (_) async => throw Exception('metadata unavailable'), 189 + ); 190 + when(() => mockDatabase.getActiveAccount()).thenAnswer((_) async => account); 191 + 192 + final restored = await authRepository.restoreSession(); 193 + 194 + expect(restored, isNotNull); 195 + expect(restored!.did, equals(account.did)); 196 + expect(restored.isExpired, isTrue); 197 + verifyNever(() => mockDatabase.deleteAccount(any())); 198 + verifyNever(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)); 199 + }); 200 + }); 201 + 202 + group('app password refresh', () { 203 + test('preserves account when refresh fails transiently', () async { 204 + authRepository = AuthRepository( 205 + database: mockDatabase, 206 + appPasswordRefreshSession: ({required String refreshJwt, String? service}) async => 207 + throw Exception('refresh service unavailable'), 208 + ); 209 + 210 + const currentSession = AuthTokens( 211 + accessToken: 'expired-access-token', 212 + refreshToken: 'refresh-token', 213 + did: 'did:plc:abc123', 214 + handle: 'user.bsky.social', 215 + service: 'bsky.social', 216 + authMethod: AuthMethod.appPassword, 217 + ); 218 + 219 + await expectLater(authRepository.refreshSession(currentSession), throwsA(isA<Exception>())); 220 + 221 + verifyNever(() => mockDatabase.deleteAccount(any())); 222 + verifyNever(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)); 223 + }); 224 + 225 + test('invalidates account when refresh token is rejected', () async { 226 + authRepository = AuthRepository( 227 + database: mockDatabase, 228 + appPasswordRefreshSession: ({required String refreshJwt, String? service}) async => 229 + throw _unauthorizedRefreshException(), 230 + ); 231 + 232 + const currentSession = AuthTokens( 233 + accessToken: 'expired-access-token', 234 + refreshToken: 'refresh-token', 235 + did: 'did:plc:abc123', 236 + handle: 'user.bsky.social', 237 + service: 'bsky.social', 238 + authMethod: AuthMethod.appPassword, 239 + ); 240 + 241 + when(() => mockDatabase.deleteAccount(currentSession.did)).thenAnswer((_) async => 1); 242 + when( 243 + () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), 244 + ).thenAnswer((_) async => currentSession.did); 245 + when(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).thenAnswer((_) async => 1); 246 + 247 + await expectLater(authRepository.refreshSession(currentSession), throwsA(isA<Exception>())); 248 + 249 + verify(() => mockDatabase.deleteAccount(currentSession.did)).called(1); 250 + verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); 251 + }); 166 252 }); 167 253 168 254 group('oauth refresh', () { ··· 394 480 395 481 expect(refreshed, isNotNull); 396 482 expect(requestedClientIds, equals([AuthRepository.kClientId])); 483 + }); 484 + 485 + test('preserves account when OAuth refresh fails transiently', () async { 486 + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; 487 + final expiredAccessToken = _buildJwt( 488 + sub: 'did:plc:abc123', 489 + expEpochSeconds: nowEpochSeconds - 3600, 490 + iatEpochSeconds: nowEpochSeconds - 7200, 491 + aud: 'did:web:porcini.us-east.host.bsky.network', 492 + iss: 'https://bsky.social', 493 + ); 494 + 495 + authRepository = AuthRepository( 496 + database: mockDatabase, 497 + loadClientMetadata: (_) async => _testClientMetadata(), 498 + oauthRefreshSession: 499 + ({required OAuthClientMetadata metadata, required String service, required OAuthSession session}) async { 500 + throw const OAuthException('{"error":"temporarily_unavailable"}'); 501 + }, 502 + ); 503 + 504 + final currentSession = AuthTokens( 505 + accessToken: expiredAccessToken, 506 + refreshToken: 'refresh-token', 507 + did: 'did:plc:abc123', 508 + handle: 'user.bsky.social', 509 + service: 'porcini.us-east.host.bsky.network', 510 + oauthService: 'bsky.social', 511 + oauthClientId: AuthRepository.kClientId, 512 + dpopNonce: 'nonce', 513 + dpopPublicKey: 'public-key', 514 + dpopPrivateKey: 'private-key', 515 + authMethod: AuthMethod.oauth, 516 + ); 517 + 518 + await expectLater(authRepository.refreshSession(currentSession), throwsA(isA<Exception>())); 519 + 520 + verifyNever(() => mockDatabase.deleteAccount(any())); 521 + verifyNever(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)); 522 + }); 523 + 524 + test('invalidates account when OAuth refresh token is rejected', () async { 525 + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; 526 + final expiredAccessToken = _buildJwt( 527 + sub: 'did:plc:abc123', 528 + expEpochSeconds: nowEpochSeconds - 3600, 529 + iatEpochSeconds: nowEpochSeconds - 7200, 530 + aud: 'did:web:porcini.us-east.host.bsky.network', 531 + iss: 'https://bsky.social', 532 + ); 533 + 534 + authRepository = AuthRepository( 535 + database: mockDatabase, 536 + loadClientMetadata: (_) async => _testClientMetadata(), 537 + oauthRefreshSession: 538 + ({required OAuthClientMetadata metadata, required String service, required OAuthSession session}) async { 539 + throw const OAuthException('{"error":"invalid_grant"}'); 540 + }, 541 + ); 542 + 543 + final currentSession = AuthTokens( 544 + accessToken: expiredAccessToken, 545 + refreshToken: 'refresh-token', 546 + did: 'did:plc:abc123', 547 + handle: 'user.bsky.social', 548 + service: 'porcini.us-east.host.bsky.network', 549 + oauthService: 'bsky.social', 550 + oauthClientId: AuthRepository.kClientId, 551 + dpopNonce: 'nonce', 552 + dpopPublicKey: 'public-key', 553 + dpopPrivateKey: 'private-key', 554 + authMethod: AuthMethod.oauth, 555 + ); 556 + 557 + when(() => mockDatabase.deleteAccount(currentSession.did)).thenAnswer((_) async => 1); 558 + when( 559 + () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), 560 + ).thenAnswer((_) async => currentSession.did); 561 + when(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).thenAnswer((_) async => 1); 562 + 563 + await expectLater(authRepository.refreshSession(currentSession), throwsA(isA<Exception>())); 564 + 565 + verify(() => mockDatabase.deleteAccount(currentSession.did)).called(1); 566 + verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); 567 + }); 568 + 569 + test('does not invalidate when only fallback OAuth candidates reject credentials', () async { 570 + final attemptedServices = <String>[]; 571 + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; 572 + final expiredAccessToken = _buildJwt( 573 + sub: 'did:plc:abc123', 574 + expEpochSeconds: nowEpochSeconds - 3600, 575 + iatEpochSeconds: nowEpochSeconds - 7200, 576 + aud: 'did:web:porcini.us-east.host.bsky.network', 577 + iss: 'https://issuer.example', 578 + ); 579 + 580 + authRepository = AuthRepository( 581 + database: mockDatabase, 582 + loadClientMetadata: (_) async => _testClientMetadata(), 583 + oauthRefreshSession: 584 + ({required OAuthClientMetadata metadata, required String service, required OAuthSession session}) async { 585 + attemptedServices.add(service); 586 + if (service == 'issuer.example') { 587 + throw const OAuthException('{"error":"temporarily_unavailable"}'); 588 + } 589 + throw const OAuthException('{"error":"invalid_grant"}'); 590 + }, 591 + ); 592 + 593 + final currentSession = AuthTokens( 594 + accessToken: expiredAccessToken, 595 + refreshToken: 'refresh-token', 596 + did: 'did:plc:abc123', 597 + handle: 'user.bsky.social', 598 + service: 'porcini.us-east.host.bsky.network', 599 + oauthService: 'stale-auth.example', 600 + oauthClientId: AuthRepository.kClientId, 601 + dpopNonce: 'nonce', 602 + dpopPublicKey: 'public-key', 603 + dpopPrivateKey: 'private-key', 604 + authMethod: AuthMethod.oauth, 605 + ); 606 + 607 + await expectLater(authRepository.refreshSession(currentSession), throwsA(isA<Exception>())); 608 + 609 + expect(attemptedServices, equals(['issuer.example', 'stale-auth.example', 'bsky.social'])); 610 + verifyNever(() => mockDatabase.deleteAccount(any())); 611 + verifyNever(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)); 397 612 }); 398 613 }); 399 614 ··· 860 1075 ), 861 1076 rateLimit: atcore.RateLimit.unlimited(), 862 1077 data: const atcore.XRPCError(error: 'InvalidRequest', message: 'Could not resolve handle'), 1078 + ), 1079 + ); 1080 + } 1081 + 1082 + atcore.UnauthorizedException _unauthorizedRefreshException() { 1083 + return atcore.UnauthorizedException( 1084 + atcore.XRPCResponse( 1085 + headers: const {}, 1086 + status: atcore.HttpStatus.unauthorized, 1087 + request: atcore.XRPCRequest( 1088 + method: atcore.HttpMethod.post, 1089 + url: Uri.https('bsky.social', '/xrpc/com.atproto.server.refreshSession'), 1090 + ), 1091 + rateLimit: atcore.RateLimit.unlimited(), 1092 + data: const atcore.XRPCError(error: 'ExpiredToken', message: 'Refresh token rejected'), 863 1093 ), 864 1094 ); 865 1095 }