Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: session refresh loop in all request interceptors #59

Merged
merged 2 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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
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
35 changes: 30 additions & 5 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 @@ -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) {
Expand All @@ -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,
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
Loading