From 11e9a4b4dad5cd50daf664aa0fa3e271da245996 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 5 Nov 2026 20:57:09 +0600 Subject: [PATCH] fix(mobile): clear the app's cookies on logout (#32965) --- .../alextran/immich/core/HttpClientManager.kt | 10 ++++++ .../alextran/immich/core/NetworkApiPlugin.kt | 4 +++ mobile/ios/Runner/Core/NetworkApiImpl.swift | 4 +++ .../ios/Runner/Core/URLSessionManager.swift | 13 ++++++-- mobile/lib/services/auth.service.dart | 2 ++ mobile/pigeon/network_api.dart | 2 ++ mobile/test/services/auth.service_test.dart | 33 +++++++++++++++++-- 7 files changed, 64 insertions(+), 4 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 73f7a0918..f5e0f621f 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -267,6 +267,10 @@ object HttpClientManager { } } + fun clearCookies() { + cookieJar.clear() + } + fun loadCookieHeader(url: String): String? { val httpUrl = url.toHttpUrlOrNull() ?: return null return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() } @@ -470,6 +474,12 @@ object HttpClientManager { return store.filter { it.matches(url) } } + @Synchronized + fun clear() { + store.clear() + persist() + } + private fun syncAuthCookies(): Boolean { val serverHosts = serverUrls.map { it.host }.toSet() val now = System.currentTimeMillis() diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt index 4479ec770..045174f70 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -84,4 +84,8 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi { override fun setRequestHeaders(headers: Map, serverUrls: List, token: String?) { HttpClientManager.setRequestHeaders(headers, serverUrls, token) } + + override fun clearCookies() { + HttpClientManager.clearCookies() + } } diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift index 288b8e953..7d80261aa 100644 --- a/mobile/ios/Runner/Core/NetworkApiImpl.swift +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -100,6 +100,10 @@ class NetworkApiImpl: NetworkApi { URLSessionManager.shared.recreateSession() } } + + func clearCookies() throws { + URLSessionManager.clearCookies() + } } private class CertImporter: NSObject, UIDocumentPickerDelegate { diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 48963aa57..14a7a1aa8 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -58,6 +58,7 @@ class URLSessionManager: NSObject { static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP) private static var serverUrls: [String] = [] private static var isSyncing = false + private static let cookieQueue = DispatchQueue(label: "CookieSync") var sessionPointer: UnsafeMutableRawPointer { Unmanaged.passUnretained(session).toOpaque() @@ -84,12 +85,20 @@ class URLSessionManager: NSObject { guard urls != serverUrls else { return } serverUrls = urls UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY) - syncAuthCookies() + cookieQueue.sync { syncAuthCookies() } + } + + static func clearCookies() { + cookieQueue.sync { + for cookie in cookieStorage.cookies ?? [] { + cookieStorage.deleteCookie(cookie) + } + } } @objc private static func cookiesDidChange(_ notification: Notification) { guard !isSyncing, !serverUrls.isEmpty else { return } - syncAuthCookies() + cookieQueue.async { syncAuthCookies() } } private static func syncAuthCookies() { diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 0c05923d2..07a521bca 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -120,6 +121,7 @@ class AuthService { _authRepository.clearLocalData(), Store.delete(StoreKey.currentUser), Store.delete(StoreKey.accessToken), + networkApi.clearCookies(), SettingsRepository.instance.clear(const [ .networkAutoEndpointSwitching, .networkPreferredWifiName, diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart index a70199534..8c7c166c6 100644 --- a/mobile/pigeon/network_api.dart +++ b/mobile/pigeon/network_api.dart @@ -45,5 +45,7 @@ abstract class NetworkApi { void setRequestHeaders(Map headers, List serverUrls, String? token); + void clearCookies(); + String getAppGroupId(); } diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index 663e39745..b13341cc3 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -1,11 +1,13 @@ import 'package:drift/drift.dart' hide isNull; import 'package:drift/native.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/data/db/main/database.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; +import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; @@ -34,9 +36,10 @@ void main() { }); setUpAll(() async { - WidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); await StoreService.init(storeRepository: StoreRepository(db)); + await SettingsRepository.ensureInitialized(db); }); tearDownAll(() async { @@ -238,4 +241,30 @@ void main() { verify(() => apiService.resolveAndSetEndpoint('https://external.endpoint')).called(1); }); }); + + group('logout', () { + test('Should clear native cookies after the server logout fails', () async { + final calls = []; + when(() => authApiRepository.logout()).thenAnswer((_) async { + calls.add('logout'); + throw ApiException(503, 'Server is not reachable'); + }); + when(() => backgroundSyncManager.cancel()).thenAnswer((_) async {}); + when(() => authRepository.clearLocalData()).thenAnswer((_) async {}); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler( + const BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.NetworkApi.clearCookies', + NetworkApi.pigeonChannelCodec, + ), + (_) async { + calls.add('clearCookies'); + return [null]; + }, + ); + + await sut.logout(); + + expect(calls, ['logout', 'clearCookies']); + }); + }); }