diff --git a/lib/app/lazurite_app.dart b/lib/app/lazurite_app.dart index 71bdd48..6ea9fd2 100644 --- a/lib/app/lazurite_app.dart +++ b/lib/app/lazurite_app.dart @@ -141,7 +141,7 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { late String _observedAppViewProvider; var _routerGeneration = 0; var _isSoftRestarting = false; - Completer? _authRecoveryCompleter; + final Map> _authRecoveryCompletersByDid = {}; @override void initState() { @@ -229,22 +229,22 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { } Future _recoverAuthSession({required String trigger}) async { - final inFlight = _authRecoveryCompleter; - if (inFlight != null) { - return inFlight.future; - } - - final completer = Completer(); - _authRecoveryCompleter = completer; String? refreshingDid; + Completer? completer; try { final authState = widget.authBloc.state; final tokens = authState.tokens; if (!authState.isAuthenticated || tokens == null || tokens.refreshToken == null) { - completer.complete(null); return null; } refreshingDid = tokens.did; + final inFlight = _authRecoveryCompletersByDid[refreshingDid]; + if (inFlight != null) { + return inFlight.future; + } + + completer = Completer(); + _authRecoveryCompletersByDid[refreshingDid] = completer; final refreshed = await widget.authRepository.refreshSession(tokens); if (!_canPublishRecoveryForDid(refreshingDid)) { @@ -264,11 +264,11 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { if (_canPublishRecoveryForDid(refreshingDid)) { widget.authBloc.add(const CheckSessionRequested()); } - completer.complete(null); + completer?.complete(null); return null; } finally { - if (identical(_authRecoveryCompleter, completer)) { - _authRecoveryCompleter = null; + if (refreshingDid != null && identical(_authRecoveryCompletersByDid[refreshingDid], completer)) { + _authRecoveryCompletersByDid.remove(refreshingDid); } } } diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index f10cdbb..3270af3 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -37,6 +37,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); static const activeAccountDidSettingKey = 'active_account_did'; + static const _authRefreshLockSettingPrefix = 'auth_refresh_lock::'; Future _serializedWriteTail = Future.value(); @override @@ -321,6 +322,38 @@ class AppDatabase extends _$AppDatabase { Future deleteSetting(String key) => (delete(settings)..where((s) => s.key.equals(key))).go(); + Future acquireAuthRefreshLock(String did, {required String owner, required DateTime expiresAt}) async { + final rowsAffected = await customUpdate( + ''' + INSERT INTO settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at + WHERE settings.updated_at < ? + ''', + variables: [ + Variable.withString(_authRefreshLockKey(did)), + Variable.withString(owner), + Variable.withDateTime(expiresAt.toUtc()), + Variable.withDateTime(DateTime.now().toUtc()), + ], + updates: {settings}, + ); + return rowsAffected > 0; + } + + Future isAuthRefreshLockActive(String did) async { + final setting = await (select(settings)..where((s) => s.key.equals(_authRefreshLockKey(did)))).getSingleOrNull(); + return setting != null && setting.updatedAt.toUtc().isAfter(DateTime.now().toUtc()); + } + + Future releaseAuthRefreshLock(String did, {required String owner}) { + return (delete(settings)..where((s) => s.key.equals(_authRefreshLockKey(did)) & s.value.equals(owner))).go(); + } + + static String _authRefreshLockKey(String did) => '$_authRefreshLockSettingPrefix$did'; + Future clearLocalCaches() async { await runSerializedWrite(() async { await transaction(() async { @@ -548,9 +581,7 @@ class AppDatabase extends _$AppDatabase { return query.write(draft.copyWith(updatedAt: Value(DateTime.now()))); } - Future deleteDraft(int id) async { - return (delete(drafts)..where((d) => d.id.equals(id))).go(); - } + Future deleteDraft(int id) async => (delete(drafts)..where((d) => d.id.equals(id))).go(); Future deleteAllDrafts(String accountDid) async { return (delete(drafts)..where((d) => d.accountDid.equals(accountDid))).go(); @@ -574,21 +605,16 @@ class AppDatabase extends _$AppDatabase { return saved != null; } - Future savePost(SavedPostsCompanion post) async { - return into(savedPosts).insert(post); - } + Future savePost(SavedPostsCompanion post) async => into(savedPosts).insert(post); Future unsavePost(String accountDid, String postUri) async { return (delete(savedPosts)..where((s) => s.accountDid.equals(accountDid) & s.postUri.equals(postUri))).go(); } - Future unsavePostById(int id) async { - return (delete(savedPosts)..where((s) => s.id.equals(id))).go(); - } + Future unsavePostById(int id) async => (delete(savedPosts)..where((s) => s.id.equals(id))).go(); - Future deleteAllSavedPosts(String accountDid) async { - return (delete(savedPosts)..where((s) => s.accountDid.equals(accountDid))).go(); - } + Future deleteAllSavedPosts(String accountDid) async => + (delete(savedPosts)..where((s) => s.accountDid.equals(accountDid))).go(); Future updateSaveType(String accountDid, String postUri, String saveType) async { final query = update(savedPosts)..where((s) => s.accountDid.equals(accountDid) & s.postUri.equals(postUri)); @@ -596,24 +622,20 @@ class AppDatabase extends _$AppDatabase { return rowsAffected > 0; } - Stream> watchSavedPosts(String accountDid) { - return (select(savedPosts) - ..where((s) => s.accountDid.equals(accountDid)) - ..orderBy([(s) => OrderingTerm.desc(s.savedAt)])) - .watch(); - } + Stream> watchSavedPosts(String accountDid) => + (select(savedPosts) + ..where((s) => s.accountDid.equals(accountDid)) + ..orderBy([(s) => OrderingTerm.desc(s.savedAt)])) + .watch(); - Stream watchIsPostSaved(String accountDid, String postUri) { - return (select(savedPosts)..where((s) => s.accountDid.equals(accountDid) & s.postUri.equals(postUri))) - .watchSingleOrNull() - .map((saved) => saved != null); - } + Stream watchIsPostSaved(String accountDid, String postUri) => + (select(savedPosts)..where((s) => s.accountDid.equals(accountDid) & s.postUri.equals(postUri))) + .watchSingleOrNull() + .map((saved) => saved != null); - Stream> watchSavedPostUris(String accountDid) { - return (select( - savedPosts, - )..where((s) => s.accountDid.equals(accountDid))).watch().map((posts) => posts.map((p) => p.postUri).toSet()); - } + Stream> watchSavedPostUris(String accountDid) => (select( + savedPosts, + )..where((s) => s.accountDid.equals(accountDid))).watch().map((posts) => posts.map((p) => p.postUri).toSet()); Stream> watchSavedPostsWithType(String accountDid) { return (select(savedPosts)..where((s) => s.accountDid.equals(accountDid))).watch().map( @@ -712,17 +734,15 @@ class AppDatabase extends _$AppDatabase { return _mapKeywordPostMatches(rows); } - List _mapKeywordPostMatches(List rows) { - return rows - .map( - (row) => KeywordPostMatch( - postUri: row.read('post_uri'), - source: row.read('source'), - rank: row.read('rank'), - ), - ) - .toList(growable: false); - } + List _mapKeywordPostMatches(List rows) => rows + .map( + (row) => KeywordPostMatch( + postUri: row.read('post_uri'), + source: row.read('source'), + rank: row.read('rank'), + ), + ) + .toList(growable: false); Future _createPostSearchFtsSchema() async { await customStatement(''' @@ -884,11 +904,10 @@ class AppDatabase extends _$AppDatabase { return false; } - Future getNotificationDelivery(String accountDid, String notificationUri) { - return (select(notificationDeliveries) - ..where((entry) => entry.accountDid.equals(accountDid) & entry.notificationUri.equals(notificationUri))) - .getSingleOrNull(); - } + Future getNotificationDelivery(String accountDid, String notificationUri) => + (select(notificationDeliveries) + ..where((entry) => entry.accountDid.equals(accountDid) & entry.notificationUri.equals(notificationUri))) + .getSingleOrNull(); Future countNotificationDeliveries(String accountDid) async { final rows = await (select(notificationDeliveries)..where((entry) => entry.accountDid.equals(accountDid))).get(); diff --git a/lib/core/network/unauthorized_recovery_runner.dart b/lib/core/network/unauthorized_recovery_runner.dart index 2782818..ae51c76 100644 --- a/lib/core/network/unauthorized_recovery_runner.dart +++ b/lib/core/network/unauthorized_recovery_runner.dart @@ -1,5 +1,5 @@ -import 'package:poptart_core/poptart_core.dart' as atcore show UnauthorizedException; import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore show UnauthorizedException; typedef UnauthorizedRecoveryCallback = Future Function(); typedef UnauthorizedClientFactory = TClient? Function(AuthTokens tokens); @@ -11,14 +11,17 @@ final class UnauthorizedRecoveryRunner { required TClient initialClient, required UnauthorizedRecoveryCallback? onUnauthorized, required UnauthorizedClientFactory clientFactory, + String? expectedDid, this.onUnauthorizedException, }) : _client = initialClient, _onUnauthorized = onUnauthorized, - _clientFactory = clientFactory; + _clientFactory = clientFactory, + _expectedDid = expectedDid; TClient _client; final UnauthorizedRecoveryCallback? _onUnauthorized; final UnauthorizedClientFactory _clientFactory; + final String? _expectedDid; final UnauthorizedRecoveryLogger? onUnauthorizedException; TClient get client => _client; @@ -47,6 +50,10 @@ final class UnauthorizedRecoveryRunner { return false; } + if (_expectedDid != null && refreshedTokens.did != _expectedDid) { + return false; + } + final refreshedClient = _clientFactory(refreshedTokens); if (refreshedClient == null) { return false; diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 0e89ba9..c935d3e 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -119,6 +119,9 @@ class AuthRepository { 'OAUTH_IOS_HTTPS_CALLBACK_ENABLED', defaultValue: true, ); + static const Duration _refreshLockLease = Duration(seconds: 30); + static const Duration _refreshLockPollInterval = Duration(milliseconds: 100); + static const Duration _refreshLockWait = Duration(seconds: 5); static final Uri _mobileOAuthRedirectUri = Uri.parse('$_mobileOAuthRedirectScheme:$_mobileOAuthRedirectPath'); static final Uri _httpsOAuthRedirectUri = Uri.https(_httpsOAuthRedirectHost, _httpsOAuthRedirectPath); @@ -382,7 +385,7 @@ class AuthRepository { } late final Future refresh; - refresh = _refreshSession(currentSession).whenComplete(() { + refresh = _refreshSessionWithPersistentLock(currentSession).whenComplete(() { if (identical(_sessionRefreshesByDid[currentSession.did], refresh)) { _sessionRefreshesByDid.remove(currentSession.did); } @@ -391,6 +394,59 @@ class AuthRepository { return refresh; } + Future _refreshSessionWithPersistentLock(AuthTokens currentSession) async { + final owner = _refreshLockOwner(currentSession); + + while (true) { + final acquired = await _database.acquireAuthRefreshLock( + currentSession.did, + owner: owner, + expiresAt: DateTime.now().toUtc().add(_refreshLockLease), + ); + if (acquired) { + try { + return await _refreshSession(currentSession); + } finally { + await _database.releaseAuthRefreshLock(currentSession.did, owner: owner); + } + } + + final refreshedByOtherWorker = await _waitForCompetingRefresh(currentSession); + if (refreshedByOtherWorker != null) { + return refreshedByOtherWorker; + } + + final storedAccount = await _database.getAccount(currentSession.did); + if (storedAccount == null) { + log.w( + 'AuthRepository: Session for ${currentSession.handle} disappeared while waiting for refresh lock; ' + 'not refreshing stale tokens.', + ); + return null; + } + } + } + + Future _waitForCompetingRefresh(AuthTokens currentSession) async { + final deadline = DateTime.now().toUtc().add(_refreshLockWait); + while (DateTime.now().toUtc().isBefore(deadline)) { + await Future.delayed(_refreshLockPollInterval); + final storedReplacement = await _storedSessionIfRefreshTokenChanged(currentSession); + if (storedReplacement != null) { + log.i('AuthRepository: Using session refreshed by another worker for ${currentSession.handle}.'); + return storedReplacement; + } + if (!await _database.isAuthRefreshLockActive(currentSession.did)) { + return null; + } + } + return null; + } + + String _refreshLockOwner(AuthTokens session) { + return '${DateTime.now().toUtc().microsecondsSinceEpoch}-$hashCode-${identityHashCode(session)}'; + } + Future _refreshSession(AuthTokens currentSession) async { var session = currentSession; final storedReplacement = await _storedSessionIfRefreshTokenChanged(currentSession); @@ -1068,9 +1124,7 @@ class AuthRepository { ); } - static String _sanitizeUriForLog(Uri uri) { - return uri.replace(query: null, fragment: null).toString(); - } + static String _sanitizeUriForLog(Uri uri) => uri.replace(query: null, fragment: null).toString(); Future _invalidateSession(AuthTokens tokens) async { await _database.deleteAccount(tokens.did); @@ -1121,10 +1175,9 @@ class AuthRepository { log.w( 'AuthRepository: Refresh compare-and-swap found no current row for ${previousSession.handle}; ' - 'falling back to session upsert.', + 'not re-saving refreshed tokens because the account may have been removed.', ); - await saveSession(mergedSession, makeActive: makeActive); - return mergedSession; + throw StateError('Session changed or was removed during refresh for ${previousSession.handle}'); } AuthTokens _mergeRefreshedSession({required AuthTokens previousSession, required AuthTokens refreshedSession}) { @@ -1281,9 +1334,7 @@ class AuthRepository { static Future> _defaultAppPasswordRefreshSession({ required String refreshJwt, String? service, - }) { - return atp.refreshSession(refreshJwt: refreshJwt, service: service); - } + }) => atp.refreshSession(refreshJwt: refreshJwt, service: service); static Future<(Uri, OAuthContext)> _defaultOAuthAuthorizeSession(OAuthClient client, String? identity) { return client.authorize(identity); @@ -1293,9 +1344,7 @@ class AuthRepository { OAuthClient client, String callbackUrl, OAuthContext context, - ) { - return client.callback(callbackUrl, context); - } + ) => client.callback(callbackUrl, context); static String _defaultOAuthServiceResolver() { return AppViewProviders.descriptorForSetting(AppViewProviders.defaultKey).entrywayUrl.host; diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index bd830c7..291c560 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -1,12 +1,11 @@ -import 'package:poptart_core/poptart_core.dart' as atcore show AtUri; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:bluesky_poptart/app/bsky/embed/record.dart'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/get_author_feed.dart'; import 'package:bluesky_poptart/app/bsky/unspecced/defs.dart'; import 'package:flutter/foundation.dart'; -import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/cache/offline_cache_policy.dart'; +import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_fallback_service.dart'; @@ -17,6 +16,7 @@ import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/data/trending_join.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore show AtUri; class FeedRepository { FeedRepository({ @@ -49,6 +49,7 @@ class FeedRepository { initialClient: bluesky, onUnauthorized: onUnauthorized, clientFactory: blueskyClientFactory ?? createBlueskyClient, + expectedDid: accountDid, onUnauthorizedException: (error, stackTrace) { log.w('feed.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); }, diff --git a/lib/features/feed/data/post_thread_repository.dart b/lib/features/feed/data/post_thread_repository.dart index c027c1c..cc9398c 100644 --- a/lib/features/feed/data/post_thread_repository.dart +++ b/lib/features/feed/data/post_thread_repository.dart @@ -33,6 +33,7 @@ class PostThreadRepository { initialClient: bluesky, onUnauthorized: onUnauthorized, clientFactory: blueskyClientFactory ?? createBlueskyClient, + expectedDid: accountDid, onUnauthorizedException: (error, stackTrace) { log.w('thread.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); }, diff --git a/lib/features/moderation/data/moderation_service.dart b/lib/features/moderation/data/moderation_service.dart index 3100ea2..303a0cb 100644 --- a/lib/features/moderation/data/moderation_service.dart +++ b/lib/features/moderation/data/moderation_service.dart @@ -42,6 +42,7 @@ class ModerationService { initialClient: bluesky, onUnauthorized: onUnauthorized, clientFactory: blueskyClientFactory ?? createBlueskyClient, + expectedDid: accountDid ?? userDid, ); _headers = _appViewContext.appBskyHeadersForEndpoint( 'app.bsky.labeler.getServices', diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart index f1c8294..141099b 100644 --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -504,6 +504,53 @@ void main() { expect(value, equals('3')); }); + + test('auth refresh lock is exclusive until released', () async { + final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 1)); + + final firstAcquire = await database.acquireAuthRefreshLock( + 'did:plc:test', + owner: 'worker-a', + expiresAt: expiresAt, + ); + final secondAcquire = await database.acquireAuthRefreshLock( + 'did:plc:test', + owner: 'worker-b', + expiresAt: expiresAt, + ); + + expect(firstAcquire, isTrue); + expect(secondAcquire, isFalse); + expect(await database.isAuthRefreshLockActive('did:plc:test'), isTrue); + + await database.releaseAuthRefreshLock('did:plc:test', owner: 'worker-a'); + + final thirdAcquire = await database.acquireAuthRefreshLock( + 'did:plc:test', + owner: 'worker-b', + expiresAt: expiresAt, + ); + expect(thirdAcquire, isTrue); + }); + + test('auth refresh lock can be stolen after lease expiry', () async { + final expiredAt = DateTime.now().toUtc().subtract(const Duration(seconds: 1)); + final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 1)); + + final firstAcquire = await database.acquireAuthRefreshLock( + 'did:plc:test', + owner: 'worker-a', + expiresAt: expiredAt, + ); + final secondAcquire = await database.acquireAuthRefreshLock( + 'did:plc:test', + owner: 'worker-b', + expiresAt: expiresAt, + ); + + expect(firstAcquire, isTrue); + expect(secondAcquire, isTrue); + }); }); }); } diff --git a/test/core/network/unauthorized_recovery_runner_test.dart b/test/core/network/unauthorized_recovery_runner_test.dart new file mode 100644 index 0000000..2dc2d9b --- /dev/null +++ b/test/core/network/unauthorized_recovery_runner_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; +import 'package:lazurite/shared/utils/test_utils.dart'; +import 'package:poptart_core/poptart_core.dart' show UnauthorizedException; + +void main() { + group('UnauthorizedRecoveryRunner', () { + test('does not rebuild client when recovered tokens are for a different DID', () async { + final runner = UnauthorizedRecoveryRunner<_Client>( + initialClient: const _Client('initial'), + onUnauthorized: () async => testAuthTokens(did: 'did:plc:other'), + clientFactory: (tokens) => _Client(tokens.did), + expectedDid: 'did:plc:expected', + ); + + await expectLater( + runner.run((client) async => throw testUnauthorizedException('app.bsky.feed.getTimeline')), + throwsA(isA()), + ); + + expect(runner.client.id, 'initial'); + }); + + test('rebuilds client and retries when recovered tokens match expected DID', () async { + final runner = UnauthorizedRecoveryRunner<_Client>( + initialClient: const _Client('initial'), + onUnauthorized: () async => testAuthTokens(did: 'did:plc:expected'), + clientFactory: (tokens) => _Client(tokens.did), + expectedDid: 'did:plc:expected', + ); + var calls = 0; + + final result = await runner.run((client) async { + calls += 1; + if (calls == 1) { + throw testUnauthorizedException('app.bsky.feed.getTimeline'); + } + return client.id; + }); + + expect(result, 'did:plc:expected'); + expect(calls, 2); + }); + }); +} + +class _Client { + const _Client(this.id); + + final String id; +} diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index bfc46da..2b2979a 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -31,6 +31,15 @@ void main() { mockDatabase = MockAppDatabase(); mockSlingshotClient = MockSlingshotClient(); when(() => mockDatabase.getAccount(any())).thenAnswer((_) async => null); + when( + () => mockDatabase.acquireAuthRefreshLock( + any(), + owner: any(named: 'owner'), + expiresAt: any(named: 'expiresAt'), + ), + ).thenAnswer((_) async => true); + when(() => mockDatabase.isAuthRefreshLockActive(any())).thenAnswer((_) async => false); + when(() => mockDatabase.releaseAuthRefreshLock(any(), owner: any(named: 'owner'))).thenAnswer((_) async => 1); when( () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( any(), @@ -408,6 +417,124 @@ void main() { verifyNever(() => mockDatabase.insertAccount(any())); }); + test('does not resurrect account when it is removed before refreshed tokens are persisted', () async { + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final refreshedAccessToken = buildJwt( + sub: 'did:plc:abc123', + expEpochSeconds: nowEpochSeconds + 3600, + iatEpochSeconds: nowEpochSeconds, + ); + authRepository = AuthRepository( + database: mockDatabase, + appPasswordRefreshSession: ({required String refreshJwt, String? service}) async { + return _appPasswordRefreshResponse( + did: 'did:plc:abc123', + handle: 'user.bsky.social', + accessJwt: refreshedAccessToken, + refreshJwt: 'new-refresh-token', + ); + }, + ); + + const currentSession = AuthTokens( + accessToken: 'expired-access-token', + refreshToken: 'old-refresh-token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + var getAccountCalls = 0; + when(() => mockDatabase.getAccount(currentSession.did)).thenAnswer((_) async { + getAccountCalls += 1; + return getAccountCalls == 1 ? _accountForTokens(currentSession) : null; + }); + when( + () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), + ).thenAnswer((_) async => currentSession.did); + when( + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + any(), + expectedRefreshToken: any(named: 'expectedRefreshToken'), + handle: any(named: 'handle'), + accessToken: any(named: 'accessToken'), + refreshToken: any(named: 'refreshToken'), + expiresAt: any(named: 'expiresAt'), + displayName: any(named: 'displayName'), + service: any(named: 'service'), + oauthService: any(named: 'oauthService'), + oauthClientId: any(named: 'oauthClientId'), + dpopNonce: any(named: 'dpopNonce'), + dpopPublicKey: any(named: 'dpopPublicKey'), + dpopPrivateKey: any(named: 'dpopPrivateKey'), + ), + ).thenAnswer((_) async => false); + + await expectLater(authRepository.refreshSession(currentSession), throwsA(isA())); + + verifyNever(() => mockDatabase.insertAccount(any())); + verifyNever(() => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, any())); + }); + + test('uses tokens refreshed by another worker while persistent refresh lock is held', () async { + authRepository = AuthRepository( + database: mockDatabase, + appPasswordRefreshSession: ({required String refreshJwt, String? service}) async => + throw StateError('refresh should be handled by the lock holder'), + ); + + const currentSession = AuthTokens( + accessToken: 'expired-access-token', + refreshToken: 'old-refresh-token', + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + + final newerSession = AuthTokens( + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + did: 'did:plc:abc123', + handle: 'user.bsky.social', + service: 'bsky.social', + authMethod: AuthMethod.appPassword, + ); + when( + () => mockDatabase.acquireAuthRefreshLock( + any(), + owner: any(named: 'owner'), + expiresAt: any(named: 'expiresAt'), + ), + ).thenAnswer((_) async => false); + when( + () => mockDatabase.getAccount(currentSession.did), + ).thenAnswer((_) async => _accountForTokens(newerSession)); + + final refreshed = await authRepository.refreshSession(currentSession); + + expect(refreshed, isNotNull); + expect(refreshed!.refreshToken, 'new-refresh-token'); + verifyNever( + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + any(), + expectedRefreshToken: any(named: 'expectedRefreshToken'), + handle: any(named: 'handle'), + accessToken: any(named: 'accessToken'), + refreshToken: any(named: 'refreshToken'), + expiresAt: any(named: 'expiresAt'), + displayName: any(named: 'displayName'), + service: any(named: 'service'), + oauthService: any(named: 'oauthService'), + oauthClientId: any(named: 'oauthClientId'), + dpopNonce: any(named: 'dpopNonce'), + dpopPublicKey: any(named: 'dpopPublicKey'), + dpopPrivateKey: any(named: 'dpopPrivateKey'), + ), + ); + }); + test('preserves account when refresh fails transiently', () async { authRepository = AuthRepository( database: mockDatabase, @@ -576,10 +703,23 @@ void main() { when( () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), ).thenAnswer((_) async => currentSession.did); - when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); when( - () => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, currentSession.did), - ).thenAnswer((_) async => 1); + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + any(), + expectedRefreshToken: any(named: 'expectedRefreshToken'), + handle: any(named: 'handle'), + accessToken: any(named: 'accessToken'), + refreshToken: any(named: 'refreshToken'), + expiresAt: any(named: 'expiresAt'), + displayName: any(named: 'displayName'), + service: any(named: 'service'), + oauthService: any(named: 'oauthService'), + oauthClientId: any(named: 'oauthClientId'), + dpopNonce: any(named: 'dpopNonce'), + dpopPublicKey: any(named: 'dpopPublicKey'), + dpopPrivateKey: any(named: 'dpopPrivateKey'), + ), + ).thenAnswer((_) async => true); final refreshed = await authRepository.refreshSession(sessionWithJwt); @@ -588,7 +728,7 @@ void main() { expect(attemptedServices, equals(['porcini.us-east.host.bsky.network', 'bsky.social'])); expect(refreshed.oauthService, equals('bsky.social')); verifyNever(() => mockDatabase.deleteAccount(any())); - verify(() => mockDatabase.insertAccount(any())).called(1); + verifyNever(() => mockDatabase.insertAccount(any())); }); test('preserves stored nullable OAuth fields when refresh does not re-fetch them', () async { @@ -732,10 +872,23 @@ void main() { when( () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), ).thenAnswer((_) async => currentSession.did); - when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); when( - () => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, currentSession.did), - ).thenAnswer((_) async => 1); + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + any(), + expectedRefreshToken: any(named: 'expectedRefreshToken'), + handle: any(named: 'handle'), + accessToken: any(named: 'accessToken'), + refreshToken: any(named: 'refreshToken'), + expiresAt: any(named: 'expiresAt'), + displayName: any(named: 'displayName'), + service: any(named: 'service'), + oauthService: any(named: 'oauthService'), + oauthClientId: any(named: 'oauthClientId'), + dpopNonce: any(named: 'dpopNonce'), + dpopPublicKey: any(named: 'dpopPublicKey'), + dpopPrivateKey: any(named: 'dpopPrivateKey'), + ), + ).thenAnswer((_) async => true); final refreshed = await authRepository.refreshSession(sessionWithJwt); @@ -801,10 +954,23 @@ void main() { when( () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), ).thenAnswer((_) async => currentSession.did); - when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); when( - () => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, currentSession.did), - ).thenAnswer((_) async => 1); + () => mockDatabase.updateAccountSessionIfRefreshTokenMatches( + any(), + expectedRefreshToken: any(named: 'expectedRefreshToken'), + handle: any(named: 'handle'), + accessToken: any(named: 'accessToken'), + refreshToken: any(named: 'refreshToken'), + expiresAt: any(named: 'expiresAt'), + displayName: any(named: 'displayName'), + service: any(named: 'service'), + oauthService: any(named: 'oauthService'), + oauthClientId: any(named: 'oauthClientId'), + dpopNonce: any(named: 'dpopNonce'), + dpopPublicKey: any(named: 'dpopPublicKey'), + dpopPrivateKey: any(named: 'dpopPrivateKey'), + ), + ).thenAnswer((_) async => true); final refreshed = await authRepository.refreshSession(sessionWithJwt);