diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d115c2..2580cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2024-06-05 + +### Changes + +- Fixed the session refresh loop in all the request interceptors that occurred when an API returned a 401 response despite a valid session. Interceptors now attempt to refresh the session a maximum of ten times before throwing an error. The retry limit is configurable via the `maxRetryAttemptsForSessionRefresh` option. + ## [0.5.1] - 2024-05-28 - Adds FDI 2.0 and 3.0 to the list of supported versions diff --git a/lib/src/dio-interceptor-wrapper.dart b/lib/src/dio-interceptor-wrapper.dart index 13524fd..bf16dbc 100644 --- a/lib/src/dio-interceptor-wrapper.dart +++ b/lib/src/dio-interceptor-wrapper.dart @@ -112,13 +112,38 @@ class SuperTokensInterceptorWrapper extends Interceptor { try { if (response.statusCode == SuperTokens.sessionExpiryStatusCode) { + /** + * An API may return a 401 error response even with a valid session, causing a session refresh loop in the interceptor. + * To prevent this infinite loop, we break out of the loop after retrying the original request a specified number of times. + * The maximum number of retry attempts is defined by maxRetryAttemptsForSessionRefresh config variable. + */ RequestOptions requestOptions = response.requestOptions; + int sessionRefreshAttempts = + requestOptions.extra["__supertokensSessionRefreshAttempts"] ?? 0; + if (sessionRefreshAttempts >= + SuperTokens.config.maxRetryAttemptsForSessionRefresh) { + handler.reject( + DioException( + requestOptions: response.requestOptions, + type: DioExceptionType.unknown, + error: SuperTokensException( + "Received a 401 response from ${response.requestOptions.uri}. Attempted to refresh the session and retry the request with the updated session tokens ${SuperTokens.config.maxRetryAttemptsForSessionRefresh} times, but each attempt resulted in a 401 error. The maximum session refresh limit has been reached. Please investigate your API. To increase the session refresh attempts, update maxRetryAttemptsForSessionRefresh in the config."), + ), + ); + _refreshAPILock.release(); + return; + } + requestOptions = await _removeAuthHeaderIfMatchesLocalToken(requestOptions); UnauthorisedResponse shouldRetry = await Client.onUnauthorisedResponse(_preRequestLocalSessionState); if (shouldRetry.status == UnauthorisedStatus.RETRY) { requestOptions.headers[HttpHeaders.cookieHeader] = userSetCookie; + + requestOptions.extra["__supertokensSessionRefreshAttempts"] = + sessionRefreshAttempts + 1; + Response res = await client.fetch(requestOptions); List? setCookieFromResponse = res.headers.map[HttpHeaders.setCookieHeader]; diff --git a/lib/src/supertokens-http-client.dart b/lib/src/supertokens-http-client.dart index 75ae6be..01317e4 100644 --- a/lib/src/supertokens-http-client.dart +++ b/lib/src/supertokens-http-client.dart @@ -17,6 +17,13 @@ import 'constants.dart'; /// If you use a custom client for your network calls pass an instance of it as a paramter when initialising [Client], pass [http.Client()] to use the default. ReadWriteMutex _refreshAPILock = ReadWriteMutex(); +class CustomRequest { + http.BaseRequest request; + int sessionRefreshAttempts; + + CustomRequest(this.request, this.sessionRefreshAttempts); +} + class Client extends http.BaseClient { Client({http.Client? client}) { if (client != null) { @@ -36,6 +43,11 @@ class Client extends http.BaseClient { @override Future send(http.BaseRequest request) async { + return await _sendWithRetry(CustomRequest(request, 0)); + } + + Future _sendWithRetry( + CustomRequest customRequest) async { if (Client.cookieStore == null) { Client.cookieStore = SuperTokensCookieStore(); } @@ -45,21 +57,21 @@ class Client extends http.BaseClient { "SuperTokens.initialise must be called before using Client"); } - if (SuperTokensUtils.getApiDomain(request.url.toString()) != + if (SuperTokensUtils.getApiDomain(customRequest.request.url.toString()) != SuperTokens.config.apiDomain) { - return _innerClient.send(request); + return _innerClient.send(customRequest.request); } - if (SuperTokensUtils.getApiDomain(request.url.toString()) == + if (SuperTokensUtils.getApiDomain(customRequest.request.url.toString()) == SuperTokens.refreshTokenUrl) { - return _innerClient.send(request); + return _innerClient.send(customRequest.request); } if (!Utils.shouldDoInterceptions( - request.url.toString(), + customRequest.request.url.toString(), SuperTokens.config.apiDomain, SuperTokens.config.sessionTokenBackendDomain)) { - return _innerClient.send(request); + return _innerClient.send(customRequest.request); } try { @@ -70,7 +82,7 @@ class Client extends http.BaseClient { LocalSessionState preRequestLocalSessionState; http.StreamedResponse response; try { - copiedRequest = SuperTokensUtils.copyRequest(request); + copiedRequest = SuperTokensUtils.copyRequest(customRequest.request); copiedRequest = await _removeAuthHeaderIfMatchesLocalToken(copiedRequest); preRequestLocalSessionState = @@ -127,12 +139,26 @@ class Client extends http.BaseClient { } if (response.statusCode == SuperTokens.sessionExpiryStatusCode) { - request = await _removeAuthHeaderIfMatchesLocalToken(copiedRequest); + /** + * An API may return a 401 error response even with a valid session, causing a session refresh loop in the interceptor. + * To prevent this infinite loop, we break out of the loop after retrying the original request a specified number of times. + * The maximum number of retry attempts is defined by maxRetryAttemptsForSessionRefresh config variable. + */ + if (customRequest.sessionRefreshAttempts >= + SuperTokens.config.maxRetryAttemptsForSessionRefresh) { + throw SuperTokensException( + "Received a 401 response from ${customRequest.request.url}. Attempted to refresh the session and retry the request with the updated session tokens ${SuperTokens.config.maxRetryAttemptsForSessionRefresh} times, but each attempt resulted in a 401 error. The maximum session refresh limit has been reached. Please investigate your API. To increase the session refresh attempts, update maxRetryAttemptsForSessionRefresh in the config."); + } + customRequest.sessionRefreshAttempts++; + + customRequest.request = + await _removeAuthHeaderIfMatchesLocalToken(copiedRequest); + UnauthorisedResponse shouldRetry = await onUnauthorisedResponse(preRequestLocalSessionState); if (shouldRetry.status == UnauthorisedStatus.RETRY) { // Here we use the original request because it wont contain any of the modifications we make - return await send(request); + return await _sendWithRetry(customRequest); } else { if (shouldRetry.exception != null) { throw SuperTokensException(shouldRetry.exception!.message); diff --git a/lib/src/supertokens.dart b/lib/src/supertokens.dart index 808fde6..8b79fd9 100644 --- a/lib/src/supertokens.dart +++ b/lib/src/supertokens.dart @@ -41,6 +41,7 @@ class SuperTokens { static void init({ required String apiDomain, String? apiBasePath, + int? maxRetryAttemptsForSessionRefresh, int sessionExpiredStatusCode = 401, String? sessionTokenBackendDomain, SuperTokensTokenTransferMethod? tokenTransferMethod, @@ -56,6 +57,7 @@ class SuperTokens { apiDomain, apiBasePath, sessionExpiredStatusCode, + maxRetryAttemptsForSessionRefresh, sessionTokenBackendDomain, tokenTransferMethod, eventHandler, diff --git a/lib/src/utilities.dart b/lib/src/utilities.dart index ff228ac..f5b651c 100644 --- a/lib/src/utilities.dart +++ b/lib/src/utilities.dart @@ -317,6 +317,7 @@ class NormalisedInputType { late String apiDomain; late String? apiBasePath; late int sessionExpiredStatusCode = 401; + late int maxRetryAttemptsForSessionRefresh = 10; late String? sessionTokenBackendDomain; late SuperTokensTokenTransferMethod tokenTransferMethod; late String? userDefaultSuiteName; @@ -328,6 +329,13 @@ class NormalisedInputType { String apiDomain, String? apiBasePath, int sessionExpiredStatusCode, + /** + * This specifies the maximum number of times the interceptor will attempt to refresh + * the session when a 401 Unauthorized response is received. If the number of retries + * exceeds this limit, no further attempts will be made to refresh the session, and + * and an error will be thrown. + */ + int maxRetryAttemptsForSessionRefresh, String? sessionTokenBackendDomain, SuperTokensTokenTransferMethod tokenTransferMethod, Function(Eventype)? eventHandler, @@ -337,6 +345,7 @@ class NormalisedInputType { this.apiDomain = apiDomain; this.apiBasePath = apiBasePath; this.sessionExpiredStatusCode = sessionExpiredStatusCode; + this.maxRetryAttemptsForSessionRefresh = maxRetryAttemptsForSessionRefresh; this.sessionTokenBackendDomain = sessionTokenBackendDomain; this.tokenTransferMethod = tokenTransferMethod; this.eventHandler = eventHandler!; @@ -348,6 +357,7 @@ class NormalisedInputType { String apiDomain, String? apiBasePath, int? sessionExpiredStatusCode, + int? maxRetryAttemptsForSessionRefresh, String? sessionTokenBackendDomain, SuperTokensTokenTransferMethod? tokenTransferMethod, Function(Eventype)? eventHandler, @@ -357,11 +367,19 @@ class NormalisedInputType { var _apiDOmain = NormalisedURLDomain(apiDomain); var _apiBasePath = NormalisedURLPath("/auth"); - if (apiBasePath != null) _apiBasePath = NormalisedURLPath(apiBasePath); + if (apiBasePath != null) { + _apiBasePath = NormalisedURLPath(apiBasePath); + } var _sessionExpiredStatusCode = 401; - if (sessionExpiredStatusCode != null) + if (sessionExpiredStatusCode != null) { _sessionExpiredStatusCode = sessionExpiredStatusCode; + } + + var _maxRetryAttemptsForSessionRefresh = 10; + if (maxRetryAttemptsForSessionRefresh != null) { + _maxRetryAttemptsForSessionRefresh = maxRetryAttemptsForSessionRefresh; + } String? _sessionTokenBackendDomain = null; if (sessionTokenBackendDomain != null) { @@ -376,20 +394,27 @@ class NormalisedInputType { } Function(Eventype)? _eventHandler = (_) => {}; - if (eventHandler != null) _eventHandler = eventHandler; + if (eventHandler != null) { + _eventHandler = eventHandler; + } http.Request Function(APIAction, http.Request)? _preAPIHook = (_, request) => request; - if (preAPIHook != null) _preAPIHook = preAPIHook; + if (preAPIHook != null) { + _preAPIHook = preAPIHook; + } Function(APIAction, http.Request, http.Response) _postAPIHook = (_, __, ___) => null; - if (postAPIHook != null) _postAPIHook = postAPIHook; + if (postAPIHook != null) { + _postAPIHook = postAPIHook; + } return NormalisedInputType( _apiDOmain.value, _apiBasePath.value, _sessionExpiredStatusCode, + _maxRetryAttemptsForSessionRefresh, _sessionTokenBackendDomain, _tokenTransferMethod, _eventHandler, diff --git a/lib/src/version.dart b/lib/src/version.dart index c42152c..97331b2 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -7,5 +7,5 @@ class Version { "2.0", "3.0" ]; - static String sdkVersion = "0.5.1"; + static String sdkVersion = "0.6.0"; } diff --git a/pubspec.lock b/pubspec.lock index 0293b35..72496c3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -553,10 +553,10 @@ packages: dependency: transitive description: name: vm_service - sha256: e7d5ecd604e499358c5fe35ee828c0298a320d54455e791e9dcf73486bc8d9f0 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "14.1.0" + version: "14.2.1" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0bc0daa..7093f93 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: supertokens_flutter description: SuperTokens SDK for Flutter apps -version: 0.5.1 +version: 0.6.0 homepage: https://supertokens.com/ repository: https://github.com/supertokens/supertokens-flutter issue_tracker: https://github.com/supertokens/supertokens-flutter/issues diff --git a/test/dioInterceptor_test.dart b/test/dioInterceptor_test.dart index 3159b62..ff4c146 100644 --- a/test/dioInterceptor_test.dart +++ b/test/dioInterceptor_test.dart @@ -451,4 +451,85 @@ void main() { fail("User Info API failed"); } }); + + test( + "should break out of session refresh loop after default maxRetryAttemptsForSessionRefresh value", + () async { + await SuperTokensTestUtils.startST(); + SuperTokens.init( + apiDomain: apiBasePath); + + RequestOptions req = SuperTokensTestUtils.getLoginRequestDio(); + Dio dio = setUpDio(); + var resp = await dio.fetch(req); + assert(resp.statusCode == 200, "Login req failed"); + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 0, + "refresh token count should have been 0"); + + try { + await dio.get("/throw-401"); + fail("Expected the request to throw an error"); + } on DioException catch (err) { + assert(err.error.toString() == + "Received a 401 response from http://localhost:8080/throw-401. Attempted to refresh the session and retry the request with the updated session tokens 10 times, but each attempt resulted in a 401 error. The maximum session refresh limit has been reached. Please investigate your API. To increase the session refresh attempts, update maxRetryAttemptsForSessionRefresh in the config."); + } + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 10, + "session refresh endpoint should have been called 10 times"); + }); + + test( + "should break out of session refresh loop after configured maxRetryAttemptsForSessionRefresh value", + () async { + await SuperTokensTestUtils.startST(); + SuperTokens.init( + apiDomain: apiBasePath, maxRetryAttemptsForSessionRefresh: 5); + + RequestOptions req = SuperTokensTestUtils.getLoginRequestDio(); + Dio dio = setUpDio(); + var resp = await dio.fetch(req); + assert(resp.statusCode == 200, "Login req failed"); + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 0, + "refresh token count should have been 0"); + + try { + await dio.get("/throw-401"); + fail("Expected the request to throw an error"); + } on DioException catch (err) { + assert(err.error.toString() == + "Received a 401 response from http://localhost:8080/throw-401. Attempted to refresh the session and retry the request with the updated session tokens 5 times, but each attempt resulted in a 401 error. The maximum session refresh limit has been reached. Please investigate your API. To increase the session refresh attempts, update maxRetryAttemptsForSessionRefresh in the config."); + } + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 5, + "session refresh endpoint should have been called 5 times"); + }); + + test( + "should not do session refresh if maxRetryAttemptsForSessionRefresh is 0", + () async { + await SuperTokensTestUtils.startST(); + SuperTokens.init( + apiDomain: apiBasePath, maxRetryAttemptsForSessionRefresh: 0); + + RequestOptions req = SuperTokensTestUtils.getLoginRequestDio(); + Dio dio = setUpDio(); + var resp = await dio.fetch(req); + assert(resp.statusCode == 200, "Login req failed"); + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 0, + "refresh token count should have been 0"); + + try { + await dio.get("/throw-401"); + fail("Expected the request to throw an error"); + } on DioException catch (err) { + assert(err.error.toString() == + "Received a 401 response from http://localhost:8080/throw-401. Attempted to refresh the session and retry the request with the updated session tokens 0 times, but each attempt resulted in a 401 error. The maximum session refresh limit has been reached. Please investigate your API. To increase the session refresh attempts, update maxRetryAttemptsForSessionRefresh in the config."); + } + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 0, + "session refresh endpoint should have been called 0 times"); + }); } diff --git a/test/http_interceptor_test.dart b/test/http_interceptor_test.dart new file mode 100644 index 0000000..43d10da --- /dev/null +++ b/test/http_interceptor_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supertokens_flutter/http.dart' as http; +import 'package:supertokens_flutter/supertokens.dart'; + +import 'test-utils.dart'; + +void main() { + String apiBasePath = SuperTokensTestUtils.baseUrl; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await SuperTokensTestUtils.beforeAllTest(); + }); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SuperTokensTestUtils.beforeEachTest(); + SuperTokens.isInitCalled = false; + await Future.delayed(Duration(seconds: 1), () {}); + }); + + tearDownAll(() async => await SuperTokensTestUtils.afterAllTest()); + + test( + "should break out of session refresh loop after default maxRetryAttemptsForSessionRefresh value", + () async { + await SuperTokensTestUtils.startST(); + SuperTokens.init(apiDomain: apiBasePath); + + Uri uri = Uri.parse("$apiBasePath/login"); + var loginRes = + await http.post(uri, body: {"userId": "supertokens-ios-tests"}); + + assert(loginRes.statusCode == 200, "Login req failed"); + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 0, + "refresh token count should have been 0"); + + try { + await http.get(Uri.parse("$apiBasePath/throw-401")); + fail("Expected the request to throw an error"); + } on SuperTokensException catch (err) { + assert(err.toString() == + "Received a 401 response from http://localhost:8080/throw-401. Attempted to refresh the session and retry the request with the updated session tokens 10 times, but each attempt resulted in a 401 error. The maximum session refresh limit has been reached. Please investigate your API. To increase the session refresh attempts, update maxRetryAttemptsForSessionRefresh in the config."); + } + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 10, + "session refresh endpoint should have been called 10 times"); + }); + test( + "should break out of session refresh loop after configured maxRetryAttemptsForSessionRefresh value", + () async { + await SuperTokensTestUtils.startST(); + SuperTokens.init( + apiDomain: apiBasePath, maxRetryAttemptsForSessionRefresh: 5); + + Uri uri = Uri.parse("$apiBasePath/login"); + var loginRes = + await http.post(uri, body: {"userId": "supertokens-ios-tests"}); + + assert(loginRes.statusCode == 200, "Login req failed"); + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 0, + "refresh token count should have been 0"); + + try { + await http.get(Uri.parse("$apiBasePath/throw-401")); + fail("Expected the request to throw an error"); + } on SuperTokensException catch (err) { + assert(err.toString() == + "Received a 401 response from http://localhost:8080/throw-401. Attempted to refresh the session and retry the request with the updated session tokens 5 times, but each attempt resulted in a 401 error. The maximum session refresh limit has been reached. Please investigate your API. To increase the session refresh attempts, update maxRetryAttemptsForSessionRefresh in the config."); + } + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 5, + "session refresh endpoint should have been called 5 times"); + }); + + test( + "should not do session refresh if maxRetryAttemptsForSessionRefresh is 0", + () async { + await SuperTokensTestUtils.startST(); + SuperTokens.init( + apiDomain: apiBasePath, maxRetryAttemptsForSessionRefresh: 0); + + Uri uri = Uri.parse("$apiBasePath/login"); + var loginRes = + await http.post(uri, body: {"userId": "supertokens-ios-tests"}); + + assert(loginRes.statusCode == 200, "Login req failed"); + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 0, + "refresh token count should have been 0"); + + try { + await http.get(Uri.parse("$apiBasePath/throw-401")); + fail("Expected the request to throw an error"); + } on SuperTokensException catch (err) { + assert(err.toString() == + "Received a 401 response from http://localhost:8080/throw-401. Attempted to refresh the session and retry the request with the updated session tokens 0 times, but each attempt resulted in a 401 error. The maximum session refresh limit has been reached. Please investigate your API. To increase the session refresh attempts, update maxRetryAttemptsForSessionRefresh in the config."); + } + + assert(await SuperTokensTestUtils.refreshTokenCounter() == 0, + "session refresh endpoint should have been called 0 times"); + }); +} diff --git a/testHelpers/server/index.js b/testHelpers/server/index.js index a3ff778..95d41e6 100644 --- a/testHelpers/server/index.js +++ b/testHelpers/server/index.js @@ -499,6 +499,10 @@ app.get("/testError", (req, res) => { res.status(500).send("test error message"); }); +app.get("/throw-401", (req, res) => { + res.status(401).send("Unauthorised"); +}); + app.get("/stop", async (req, res) => { process.exit(); });