diff --git a/plugins/cookie_manager/CHANGELOG.md b/plugins/cookie_manager/CHANGELOG.md index c00a7cd22..edf2dc25d 100644 --- a/plugins/cookie_manager/CHANGELOG.md +++ b/plugins/cookie_manager/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -*None.* +- Allow `Set-Cookie` to be parsed in redirect responses. ## 2.1.2 diff --git a/plugins/cookie_manager/README.md b/plugins/cookie_manager/README.md index a1ba6898c..0c121d68f 100644 --- a/plugins/cookie_manager/README.md +++ b/plugins/cookie_manager/README.md @@ -2,7 +2,7 @@ [![Pub](https://img.shields.io/pub/v/dio_cookie_manager.svg)](https://pub.dev/packages/dio_cookie_manager) -A cookie manager for [dio](https://github.com/cfug/dio). +A cookie manager for [dio](https://github.com/cfug/dio). ## Getting Started @@ -21,7 +21,7 @@ import 'package:dio/dio.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; void main() async { - final dio = Dio(); + final dio = Dio(); final cookieJar = CookieJar(); dio.interceptors.add(CookieManager(cookieJar)); // First request, and save cookies (CookieManager do it). @@ -55,7 +55,7 @@ so if the application exit, the cookies always exist unless call `delete` explic > Note: In flutter, the path passed to `PersistCookieJar` must be valid (exists in phones and with write access). > Use [path_provider](https://pub.dev/packages/path_provider) package to get the right path. -In flutter: +In flutter: ```dart Future prepareJar() async { @@ -63,8 +63,30 @@ Future prepareJar() async { final String appDocPath = appDocDir.path; final jar = PersistCookieJar( ignoreExpires: true, - storage: FileStorage(appDocPath +"/.cookies/" ), + storage: FileStorage(appDocPath + "/.cookies/"), ); dio.interceptors.add(CookieManager(jar)); } ``` + +## Handling Cookies with redirect requests + +Redirect requests require extra configuration to parse cookies correctly. +In shortly: +- Set `followRedirects` to `false`. +- Allow `statusCode` from `300` to `399` responses predicated as succeed. +- Make further requests using the `HttpHeaders.locationHeader`. + +For example: +```dart +final cookieJar = CookieJar(); +final dio = Dio() + ..interceptors.add(CookieManager(cookieJar)) + ..options.followRedirects = false + ..options.validateStatus = + (status) => status != null && status >= 200 && status < 400; +final redirected = await dio.get('/redirection'); +final response = await dio.get( + redirected.headers.value(HttpHeaders.locationHeader)!, +); +``` diff --git a/plugins/cookie_manager/lib/src/cookie_mgr.dart b/plugins/cookie_manager/lib/src/cookie_mgr.dart index b41480b58..309fd0c40 100644 --- a/plugins/cookie_manager/lib/src/cookie_mgr.dart +++ b/plugins/cookie_manager/lib/src/cookie_mgr.dart @@ -87,15 +87,35 @@ class CookieManager extends Interceptor { Future _saveCookies(Response response) async { final setCookies = response.headers[HttpHeaders.setCookieHeader]; - - if (setCookies != null) { - final cookies = setCookies - .map((str) => str.split(_setCookieReg)) - .expand((element) => element); - await cookieJar.saveFromResponse( - response.requestOptions.uri, - cookies.map((str) => Cookie.fromSetCookieValue(str)).toList(), + if (setCookies == null || setCookies.isEmpty) { + return; + } + final List cookies = setCookies + .map((str) => str.split(_setCookieReg)) + .expand((cookie) => cookie) + .where((cookie) => cookie.isNotEmpty) + .map((str) => Cookie.fromSetCookieValue(str)) + .toList(); + // Handle `Set-Cookie` when `followRedirects` is false + // and the response returns a redirect status code. + final statusCode = response.statusCode ?? 0; + // 300 indicates the URL has multiple choices, so here we use list literal. + final locations = response.headers[HttpHeaders.locationHeader] ?? []; + // We don't want to explicitly consider recursive redirections + // cookie handling here, because when `followRedirects` is set to false, + // users will be available to handle cookies themselves. + final isRedirectRequest = statusCode >= 300 && statusCode < 400; + if (isRedirectRequest && locations.isNotEmpty) { + await Future.wait( + locations.map( + (location) => cookieJar.saveFromResponse( + Uri.parse(location), + cookies, + ), + ), ); + } else { + await cookieJar.saveFromResponse(response.realUri, cookies); } } } diff --git a/plugins/cookie_manager/test/cookies_test.dart b/plugins/cookie_manager/test/cookies_test.dart index 9646ab55b..70d6870da 100644 --- a/plugins/cookie_manager/test/cookies_test.dart +++ b/plugins/cookie_manager/test/cookies_test.dart @@ -1,7 +1,9 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:test/test.dart'; @@ -57,33 +59,59 @@ void main() { cookieManager.onRequest(options, mockRequestInterceptorHandler); }); - test('testing set-cookies parsing', () async { - const List mockResponseCookies = [ - 'key=value; expires=Sun, 19 Feb 3000 00:42:14 GMT; path=/; HttpOnly; secure; SameSite=Lax', - 'key1=value1; expires=Sun, 19 Feb 3000 01:43:15 GMT; path=/; HttpOnly; secure; SameSite=Lax, ' - 'key2=value2; expires=Sat, 20 May 3000 00:43:15 GMT; path=/; HttpOnly; secure; SameSite=Lax', - ]; - const exampleUrl = 'https://example.com'; - final expectResult = 'key=value; key1=value1; key2=value2'; - - final cookieJar = CookieJar(); - final cookieManager = CookieManager(cookieJar); - final mockRequestInterceptorHandler = - MockRequestInterceptorHandler(expectResult); - final mockResponseInterceptorHandler = MockResponseInterceptorHandler(); - final requestOptions = RequestOptions(baseUrl: exampleUrl); - - final mockResponse = Response( - requestOptions: requestOptions, - headers: Headers.fromMap( - {HttpHeaders.setCookieHeader: mockResponseCookies}, - ), - ); - cookieManager.onResponse(mockResponse, mockResponseInterceptorHandler); - final options = RequestOptions(baseUrl: exampleUrl); + group('Set-Cookie', () { + test('can be parsed correctly', () async { + const List mockResponseCookies = [ + 'key=value; expires=Sun, 19 Feb 3000 00:42:14 GMT; path=/; HttpOnly; secure; SameSite=Lax', + 'key1=value1; expires=Sun, 19 Feb 3000 01:43:15 GMT; path=/; HttpOnly; secure; SameSite=Lax, ' + 'key2=value2; expires=Sat, 20 May 3000 00:43:15 GMT; path=/; HttpOnly; secure; SameSite=Lax', + ]; + const exampleUrl = 'https://example.com'; + + final expectResult = 'key=value; key1=value1; key2=value2'; + + final cookieJar = CookieJar(); + final cookieManager = CookieManager(cookieJar); + final mockRequestInterceptorHandler = + MockRequestInterceptorHandler(expectResult); + final mockResponseInterceptorHandler = MockResponseInterceptorHandler(); + final requestOptions = RequestOptions(baseUrl: exampleUrl); + + final mockResponse = Response( + requestOptions: requestOptions, + headers: Headers.fromMap( + {HttpHeaders.setCookieHeader: mockResponseCookies}, + ), + ); + cookieManager.onResponse(mockResponse, mockResponseInterceptorHandler); + final options = RequestOptions(baseUrl: exampleUrl); + + cookieManager.onRequest(options, mockRequestInterceptorHandler); + }); - cookieManager.onRequest(options, mockRequestInterceptorHandler); + test('can be saved to the location', () async { + final cookieJar = CookieJar(); + final dio = Dio() + ..httpClientAdapter = _RedirectAdapter() + ..interceptors.add(CookieManager(cookieJar)) + ..options.followRedirects = false + ..options.validateStatus = + (status) => status != null && status >= 200 && status < 400; + final response1 = await dio.get('/redirection'); + expect(response1.realUri.path, '/redirection'); + final cookies1 = await cookieJar.loadForRequest(response1.realUri); + expect(cookies1.length, 3); + final location = response1.headers.value(HttpHeaders.locationHeader)!; + final response2 = await dio.get(location); + expect(response2.realUri.path, location); + final cookies2 = await cookieJar.loadForRequest(response2.realUri); + expect(cookies2.length, 3); + expect( + response2.requestOptions.headers[HttpHeaders.cookieHeader], + 'key=value; key1=value1; key2=value2', + ); + }); }); group('Empty cookies', () { @@ -112,3 +140,39 @@ void main() { }); }); } + +class _RedirectAdapter implements HttpClientAdapter { + final HttpClientAdapter _adapter = IOHttpClientAdapter(); + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + final Uri uri = options.uri; + final int statusCode = HttpStatus.found; + if (uri.path != '/destination') { + return ResponseBody.fromString( + '', + statusCode, + headers: { + HttpHeaders.locationHeader: [ + uri.replace(path: '/destination').toString(), + ], + HttpHeaders.setCookieHeader: [ + 'key=value; expires=Sun, 19 Feb 3000 00:42:14 GMT; path=/; HttpOnly; secure; SameSite=Lax, ' + 'key1=value1; expires=Sun, 19 Feb 3000 01:43:15 GMT; path=/; HttpOnly; secure; SameSite=Lax, ' + 'key2=value2; expires=Sat, 20 May 3000 00:43:15 GMT; path=/; HttpOnly; secure; SameSite=Lax', + ], + }, + ); + } + return ResponseBody.fromString('', HttpStatus.ok); + } + + @override + void close({bool force = false}) { + _adapter.close(force: force); + } +}