diff --git a/lib/src/client.dart b/lib/src/client.dart index efecef71..a8347ddc 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -89,6 +89,8 @@ class Client extends MatrixApi { bool shareKeysWithUnverifiedDevices; + Future Function(Client client)? onSoftLogout; + // For CommandsClientExtension final Map Function(CommandArgs)> commands = {}; final Filter syncFilter; @@ -184,6 +186,13 @@ class Client extends MatrixApi { this.shareKeysWithUnverifiedDevices = true, this.enableDehydratedDevices = false, this.receiptsPublicByDefault = true, + + /// Implement your https://spec.matrix.org/v1.9/client-server-api/#soft-logout + /// logic here. + /// Set this to `refreshAccessToken()` for the easiest way to handle the + /// most common reason for soft logouts. + /// You can also perform a new login here by passing the existing deviceId. + this.onSoftLogout, }) : syncFilter = syncFilter ?? Filter( room: RoomFilter( @@ -234,6 +243,40 @@ class Client extends MatrixApi { registerDefaultCommands(); } + /// Fetches the refreshToken from the database and tries to get a new + /// access token from the server and then stores it correctly. Unlike the + /// pure API call of `Client.refresh()` this handles the complete soft + /// logout case. + /// Throws an Exception if there is no refresh token available or the + /// client is not logged in. + Future refreshAccessToken() async { + final storedClient = await database?.getClient(clientName); + final refreshToken = storedClient?.tryGet('refresh_token'); + if (refreshToken == null) { + throw Exception('No refresh token available'); + } + final homeserverUrl = homeserver?.toString(); + final userId = userID; + final deviceId = deviceID; + if (homeserverUrl == null || userId == null || deviceId == null) { + throw Exception('Cannot refresh access token when not logged in'); + } + + final tokenResponse = await refresh(refreshToken); + + accessToken = tokenResponse.accessToken; + await database?.updateClient( + homeserverUrl, + tokenResponse.accessToken, + tokenResponse.refreshToken, + userId, + deviceId, + deviceName, + prevBatch, + encryption?.pickledOlmAccount, + ); + } + /// The required name for this client. final String clientName; @@ -485,6 +528,7 @@ class Client extends MatrixApi { deviceId: deviceId, initialDeviceDisplayName: initialDeviceDisplayName, inhibitLogin: inhibitLogin, + refreshToken: refreshToken ?? onSoftLogout != null, ); // Connect if there is an access token in the response. @@ -498,6 +542,7 @@ class Client extends MatrixApi { } await init( newToken: accessToken, + newRefreshToken: response.refreshToken, newUserID: userId, newHomeserver: homeserver, newDeviceName: initialDeviceDisplayName ?? '', @@ -548,6 +593,7 @@ class Client extends MatrixApi { medium: medium, // ignore: deprecated_member_use address: address, + refreshToken: refreshToken ?? onSoftLogout != null, ); // Connect if there is an access token in the response. @@ -560,6 +606,7 @@ class Client extends MatrixApi { } await init( newToken: accessToken, + newRefreshToken: response.refreshToken, newUserID: userId, newHomeserver: homeserver_, newDeviceName: initialDeviceDisplayName ?? '', @@ -1474,6 +1521,7 @@ class Client extends MatrixApi { /// `userDeviceKeysLoading` where it is necessary. Future init({ String? newToken, + String? newRefreshToken, Uri? newHomeserver, String? newUserID, String? newDeviceName, @@ -1587,6 +1635,7 @@ class Client extends MatrixApi { await database.updateClient( homeserver.toString(), accessToken, + newRefreshToken, userID, _deviceID, _deviceName, @@ -1598,6 +1647,7 @@ class Client extends MatrixApi { clientName, homeserver.toString(), accessToken, + newRefreshToken, userID, _deviceID, _deviceName, @@ -1822,8 +1872,19 @@ class Client extends MatrixApi { onSyncStatus.add(SyncStatusUpdate(SyncStatus.error, error: SdkError(exception: e, stackTrace: s))); if (e.error == MatrixError.M_UNKNOWN_TOKEN) { - Logs().w('The user has been logged out!'); - await clear(); + final onSoftLogout = this.onSoftLogout; + if (e.raw.tryGet('soft_logout') == true && onSoftLogout != null) { + Logs().w('The user has been soft logged out! Try to login again...'); + try { + await onSoftLogout(this); + } catch (e, s) { + Logs().e('Unable to login again', e, s); + await clear(); + } + } else { + Logs().w('The user has been logged out!'); + await clear(); + } } } on SyncConnectionException catch (e, s) { Logs().w('Syncloop failed: Client has not connection to the server'); @@ -3112,6 +3173,7 @@ class Client extends MatrixApi { clientName, migrateClient['homeserver_url'], migrateClient['token'], + migrateClient['refresh_token'], migrateClient['user_id'], migrateClient['device_id'], migrateClient['device_name'], diff --git a/lib/src/database/database_api.dart b/lib/src/database/database_api.dart index 2c41c7d4..f6d15054 100644 --- a/lib/src/database/database_api.dart +++ b/lib/src/database/database_api.dart @@ -33,6 +33,7 @@ abstract class DatabaseApi { Future updateClient( String homeserverUrl, String token, + String? refreshToken, String userId, String? deviceId, String? deviceName, @@ -44,6 +45,7 @@ abstract class DatabaseApi { String name, String homeserverUrl, String token, + String? refreshToken, String userId, String? deviceId, String? deviceName, diff --git a/lib/src/database/hive_collections_database.dart b/lib/src/database/hive_collections_database.dart index ad8073a2..9582f40a 100644 --- a/lib/src/database/hive_collections_database.dart +++ b/lib/src/database/hive_collections_database.dart @@ -785,6 +785,7 @@ class HiveCollectionsDatabase extends DatabaseApi { String name, String homeserverUrl, String token, + String? refreshToken, String userId, String? deviceId, String? deviceName, @@ -794,6 +795,11 @@ class HiveCollectionsDatabase extends DatabaseApi { await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('token', token); await _clientBox.put('user_id', userId); + if (refreshToken == null) { + await _clientBox.delete('refresh_token'); + } else { + await _clientBox.put('refresh_token', refreshToken); + } if (deviceId == null) { await _clientBox.delete('device_id'); } else { @@ -1371,6 +1377,7 @@ class HiveCollectionsDatabase extends DatabaseApi { Future updateClient( String homeserverUrl, String token, + String? refreshToken, String userId, String? deviceId, String? deviceName, @@ -1380,6 +1387,11 @@ class HiveCollectionsDatabase extends DatabaseApi { await transaction(() async { await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('token', token); + if (refreshToken == null) { + await _clientBox.delete('refresh_token'); + } else { + await _clientBox.put('refresh_token', refreshToken); + } await _clientBox.put('user_id', userId); if (deviceId == null) { await _clientBox.delete('device_id'); diff --git a/lib/src/database/hive_database.dart b/lib/src/database/hive_database.dart index 8aa3706e..89576c7e 100644 --- a/lib/src/database/hive_database.dart +++ b/lib/src/database/hive_database.dart @@ -750,6 +750,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi with ZoneTransactionMixin { String name, String homeserverUrl, String token, + String? refreshToken, String userId, String? deviceId, String? deviceName, @@ -757,6 +758,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi with ZoneTransactionMixin { String? olmAccount) async { await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('token', token); + await _clientBox.put('refresh_token', refreshToken); await _clientBox.put('user_id', userId); await _clientBox.put('device_id', deviceId); await _clientBox.put('device_name', deviceName); @@ -1314,6 +1316,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi with ZoneTransactionMixin { Future updateClient( String homeserverUrl, String token, + String? refreshToken, String userId, String? deviceId, String? deviceName, @@ -1322,6 +1325,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi with ZoneTransactionMixin { ) async { await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('token', token); + await _clientBox.put('refresh_token', refreshToken); await _clientBox.put('user_id', userId); await _clientBox.put('device_id', deviceId); await _clientBox.put('device_name', deviceName); diff --git a/lib/src/database/matrix_sdk_database.dart b/lib/src/database/matrix_sdk_database.dart index 6f17970c..82a7dca3 100644 --- a/lib/src/database/matrix_sdk_database.dart +++ b/lib/src/database/matrix_sdk_database.dart @@ -727,6 +727,7 @@ class MatrixSdkDatabase extends DatabaseApi { String name, String homeserverUrl, String token, + String? refreshToken, String userId, String? deviceId, String? deviceName, @@ -735,6 +736,11 @@ class MatrixSdkDatabase extends DatabaseApi { await transaction(() async { await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('token', token); + if (refreshToken == null) { + await _clientBox.delete('refresh_token'); + } else { + await _clientBox.put('refresh_token', refreshToken); + } await _clientBox.put('user_id', userId); if (deviceId == null) { await _clientBox.delete('device_id'); @@ -1343,6 +1349,7 @@ class MatrixSdkDatabase extends DatabaseApi { Future updateClient( String homeserverUrl, String token, + String? refreshToken, String userId, String? deviceId, String? deviceName, @@ -1352,6 +1359,11 @@ class MatrixSdkDatabase extends DatabaseApi { await transaction(() async { await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('token', token); + if (refreshToken == null) { + await _clientBox.delete('refresh_token'); + } else { + await _clientBox.put('refresh_token', refreshToken); + } await _clientBox.put('user_id', userId); if (deviceId == null) { await _clientBox.delete('device_id'); diff --git a/test/client_test.dart b/test/client_test.dart index db86214e..6244e431 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -964,6 +964,35 @@ void main() { await client.dispose(closeDatabase: true); }); + test('refreshAccessToken', () async { + final client = await getClient(); + expect(client.accessToken, 'abcd'); + await client.refreshAccessToken(); + expect(client.accessToken, 'a_new_token'); + }); + + test('handleSoftLogout', () async { + final client = await getClient(); + expect(client.accessToken, 'abcd'); + var softLoggedOut = 0; + client.onSoftLogout = (client) { + softLoggedOut++; + return client.refreshAccessToken(); + }; + FakeMatrixApi.expectedAccessToken = 'a_new_token'; + await client.oneShotSync(); + await client.oneShotSync(); + FakeMatrixApi.expectedAccessToken = null; + expect(client.accessToken, 'a_new_token'); + expect(softLoggedOut, 1); + final storedClient = await client.database?.getClient(client.clientName); + expect(storedClient?.tryGet('token'), 'a_new_token'); + expect( + storedClient?.tryGet('refresh_token'), + 'another_new_token', + ); + }); + test('object equality', () async { final time1 = DateTime.fromMillisecondsSinceEpoch(1); final time2 = DateTime.fromMillisecondsSinceEpoch(0); diff --git a/test/database_api_test.dart b/test/database_api_test.dart index 0ebc5159..d043912f 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -133,6 +133,7 @@ void main() { 'name', 'homeserverUrl', 'token', + 'refresh_token', 'userId', 'deviceId', 'deviceName', @@ -147,6 +148,7 @@ void main() { await database.updateClient( 'homeserverUrl', 'token_different', + 'refresh_token', 'userId', 'deviceId', 'deviceName', diff --git a/test/fake_client.dart b/test/fake_client.dart index 15f83d14..e4cc81b6 100644 --- a/test/fake_client.dart +++ b/test/fake_client.dart @@ -32,12 +32,14 @@ Future getClient() async { 'testclient', httpClient: FakeMatrixApi(), databaseBuilder: getDatabase, + onSoftLogout: (client) => client.refreshAccessToken(), ); FakeMatrixApi.client = client; await client.checkHomeserver(Uri.parse('https://fakeServer.notExisting'), checkWellKnown: false); await client.init( newToken: 'abcd', + newRefreshToken: 'refresh_abcd', newUserID: '@test:fakeServer.notExisting', newHomeserver: client.homeserver, newDeviceName: 'Text Matrix Client', diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index b9e46353..7ae3a471 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -39,6 +39,8 @@ Map decodeJson(dynamic data) { } class FakeMatrixApi extends BaseClient { + static String? expectedAccessToken; + static Map> get calledEndpoints => currentApi!._calledEndpoints; static int get eventCounter => currentApi!._eventCounter; @@ -129,6 +131,23 @@ class FakeMatrixApi extends BaseClient { 'Not found...', 404); } + if (!{ + '/client/v3/refresh', + '/client/v3/login', + '/client/v3/register', + }.contains(action) && + expectedAccessToken != null && + request.headers['Authorization'] != 'Bearer $expectedAccessToken') { + return Response( + jsonEncode({ + 'errcode': 'M_UNKNOWN_TOKEN', + 'error': 'Soft logged out', + 'soft_logout': true, + }), + 401, + ); + } + // Call API (_calledEndpoints[action] ??= []).add(data); final act = api[method]?[action]; @@ -2013,6 +2032,11 @@ class FakeMatrixApi extends BaseClient { }, }, 'POST': { + '/client/v3/refresh': (var req) => { + 'access_token': 'a_new_token', + 'expires_in_ms': 60000, + 'refresh_token': 'another_new_token' + }, '/client/v3/delete_devices': (var req) => {}, '/client/v3/account/3pid/add': (var req) => {}, '/client/v3/account/3pid/bind': (var req) => {}, @@ -2397,6 +2421,7 @@ class FakeMatrixApi extends BaseClient { '/client/v3/login': (var req) => { 'user_id': '@test:fakeServer.notExisting', 'access_token': 'abc123', + 'refresh_token': 'refresh_abc123', 'device_id': 'GHTYAJCE', 'well_known': { 'm.homeserver': {'base_url': 'https://example.org'}, diff --git a/test/matrix_database_test.dart b/test/matrix_database_test.dart index 6c333e9b..cc8cb833 100644 --- a/test/matrix_database_test.dart +++ b/test/matrix_database_test.dart @@ -32,6 +32,7 @@ void main() { 'testclient', 'https://example.org', 'blubb', + null, '@test:example.org', null, null,