diff --git a/plugins/cookie_manager/CHANGELOG.md b/plugins/cookie_manager/CHANGELOG.md index 5c8f50138..11ec9141d 100644 --- a/plugins/cookie_manager/CHANGELOG.md +++ b/plugins/cookie_manager/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -*None.* +- Fix duplicate cookies when requests are retried with the same `RequestOptions`. ## 3.4.0 diff --git a/plugins/cookie_manager/lib/src/cookie_mgr.dart b/plugins/cookie_manager/lib/src/cookie_mgr.dart index a3a79d17a..650027e6f 100644 --- a/plugins/cookie_manager/lib/src/cookie_mgr.dart +++ b/plugins/cookie_manager/lib/src/cookie_mgr.dart @@ -149,12 +149,20 @@ class CookieManager extends Interceptor { final savedCookies = await cookieJar.loadForRequest(options.uri); final previousCookies = options.headers[HttpHeaders.cookieHeader] as String?; + // Per RFC 6265 Section 4.2, the Cookie header carries only name=value + // pairs without domain or path. The saved cookies are already scoped + // to the request URI by cookieJar.loadForRequest, so matching by name + // is sufficient to detect duplicates from a retried request. + final savedCookieNames = savedCookies.map((c) => c.name).toSet(); + final previousList = previousCookies + ?.split(';') + .where((e) => e.isNotEmpty) + .map((c) => _fromSetCookieValue(c)) + .whereType() // Use .nonNulls when the minimum SDK is 3.0. + .where((c) => !savedCookieNames.contains(c.name)) + .toList(); final cookies = getCookies([ - ...?previousCookies - ?.split(';') - .where((e) => e.isNotEmpty) - .map((c) => _fromSetCookieValue(c)) - .whereType(), // Use .nonNulls when the minimum SDK is 3.0. + ...?previousList, ...savedCookies, ]); return cookies; diff --git a/plugins/cookie_manager/test/cookies_test.dart b/plugins/cookie_manager/test/cookies_test.dart index b01f8ea41..ca1427883 100644 --- a/plugins/cookie_manager/test/cookies_test.dart +++ b/plugins/cookie_manager/test/cookies_test.dart @@ -228,6 +228,77 @@ void main() { }); }); + test('no duplicate cookies on retry', () async { + const List mockResponseCookies = [ + 'a=1; Path=/', + 'b=2; Path=/', + ]; + const exampleUrl = 'https://example.com'; + + final cookieJar = CookieJar(); + final cookieManager = CookieManager(cookieJar); + + // Save cookies from a response. + final requestOptions = RequestOptions(baseUrl: exampleUrl); + final mockResponse = Response( + requestOptions: requestOptions, + headers: Headers.fromMap( + {HttpHeaders.setCookieHeader: mockResponseCookies}, + ), + ); + await cookieManager.onResponse( + mockResponse, + MockResponseInterceptorHandler(), + ); + + // First request sets cookie header. + final firstOptions = RequestOptions(baseUrl: exampleUrl); + final firstHandler = MockRequestInterceptorHandler('a=1; b=2'); + await cookieManager.onRequest(firstOptions, firstHandler); + + // Simulate retry: onRequest is called again on the same RequestOptions + // which already has the cookie header from the first attempt. + final retryHandler = MockRequestInterceptorHandler('a=1; b=2'); + await cookieManager.onRequest(firstOptions, retryHandler); + }); + + test('same-name cookies with different paths are preserved on retry', + () async { + // RFC 6265 Section 5.3: cookies are identified by (name, domain, path). + // Two cookies with the same name but different paths must both be kept. + const List mockResponseCookies = [ + 'session=abc; Path=/', + 'session=xyz; Path=/api', + ]; + const exampleUrl = 'https://example.com/api/endpoint'; + + final cookieJar = CookieJar(); + final cookieManager = CookieManager(cookieJar); + + final requestOptions = RequestOptions(baseUrl: exampleUrl); + final mockResponse = Response( + requestOptions: requestOptions, + headers: Headers.fromMap( + {HttpHeaders.setCookieHeader: mockResponseCookies}, + ), + ); + await cookieManager.onResponse( + mockResponse, + MockResponseInterceptorHandler(), + ); + + // First request: both cookies should be sent. + final firstOptions = RequestOptions(baseUrl: exampleUrl); + final firstHandler = + MockRequestInterceptorHandler('session=xyz; session=abc'); + await cookieManager.onRequest(firstOptions, firstHandler); + + // Retry: same cookies, no duplicates. + final retryHandler = + MockRequestInterceptorHandler('session=xyz; session=abc'); + await cookieManager.onRequest(firstOptions, retryHandler); + }); + test('cookies replacement', () async { final cookies = [ Cookie('foo', 'bar')..path = '/',