Skip to content

Commit

Permalink
fix: session refresh loop in all request interceptors
Browse files Browse the repository at this point in the history
  • Loading branch information
anku255 committed Jun 5, 2024
1 parent c550c43 commit caaa078
Show file tree
Hide file tree
Showing 11 changed files with 278 additions and 13 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 returning the last API response. 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
Expand Down
25 changes: 25 additions & 0 deletions lib/src/dio-interceptor-wrapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<dynamic> res = await client.fetch(requestOptions);
List<dynamic>? setCookieFromResponse =
res.headers.map[HttpHeaders.setCookieHeader];
Expand Down
44 changes: 35 additions & 9 deletions lib/src/supertokens-http-client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -36,6 +43,11 @@ class Client extends http.BaseClient {

@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
return await _sendWithRetry(CustomRequest(request, 0));
}

Future<http.StreamedResponse> _sendWithRetry(
CustomRequest customRequest) async {
if (Client.cookieStore == null) {
Client.cookieStore = SuperTokensCookieStore();
}
Expand All @@ -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 {
Expand All @@ -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 =
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions lib/src/supertokens.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class SuperTokens {
static void init({
required String apiDomain,
String? apiBasePath,
int? maxRetryAttemptsForSessionRefresh,
int sessionExpiredStatusCode = 401,
String? sessionTokenBackendDomain,
SuperTokensTokenTransferMethod? tokenTransferMethod,
Expand All @@ -56,6 +57,7 @@ class SuperTokens {
apiDomain,
apiBasePath,
sessionExpiredStatusCode,
maxRetryAttemptsForSessionRefresh,
sessionTokenBackendDomain,
tokenTransferMethod,
eventHandler,
Expand Down
15 changes: 15 additions & 0 deletions lib/src/utilities.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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!;
Expand All @@ -348,6 +357,7 @@ class NormalisedInputType {
String apiDomain,
String? apiBasePath,
int? sessionExpiredStatusCode,
int? maxRetryAttemptsForSessionRefresh,
String? sessionTokenBackendDomain,
SuperTokensTokenTransferMethod? tokenTransferMethod,
Function(Eventype)? eventHandler,
Expand All @@ -363,6 +373,10 @@ class NormalisedInputType {
if (sessionExpiredStatusCode != null)
_sessionExpiredStatusCode = sessionExpiredStatusCode;

var _maxRetryAttemptsForSessionRefresh = 10;
if (maxRetryAttemptsForSessionRefresh != null)
_maxRetryAttemptsForSessionRefresh = maxRetryAttemptsForSessionRefresh;

String? _sessionTokenBackendDomain = null;
if (sessionTokenBackendDomain != null) {
_sessionTokenBackendDomain =
Expand Down Expand Up @@ -390,6 +404,7 @@ class NormalisedInputType {
_apiDOmain.value,
_apiBasePath.value,
_sessionExpiredStatusCode,
_maxRetryAttemptsForSessionRefresh,
_sessionTokenBackendDomain,
_tokenTransferMethod,
_eventHandler,
Expand Down
2 changes: 1 addition & 1 deletion lib/src/version.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ class Version {
"2.0",
"3.0"
];
static String sdkVersion = "0.5.1";
static String sdkVersion = "0.6.0";
}
4 changes: 2 additions & 2 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
81 changes: 81 additions & 0 deletions test/dioInterceptor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
}
Loading

0 comments on commit caaa078

Please sign in to comment.