Skip to content

Commit 97c5504

Browse files
authored
fix: session refresh loop in all request interceptors (#66)
1 parent 2854c17 commit 97c5504

File tree

9 files changed

+233
-7
lines changed

9 files changed

+233
-7
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
## [0.4.0] - 2024-06-05
10+
11+
### Changes
12+
13+
- 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.
14+
915
## [0.3.2] - 2024-05-28
1016

1117
- Readds FDI 2.0 and 3.0 support

SuperTokensIOS.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
Pod::Spec.new do |s|
1010
s.name = 'SuperTokensIOS'
11-
s.version = "0.3.2"
11+
s.version = "0.4.0"
1212
s.summary = 'SuperTokens SDK for using login and session management functionality in iOS apps'
1313

1414
# This description is used to generate tags and improve search results.

SuperTokensIOS/Classes/Error.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public enum SuperTokensError: Error {
2020
case apiError(message: String)
2121
case generalError(message: String)
2222
case illegalAccess(message: String)
23+
case maxRetryAttemptsReachedForSessionRefresh(message: String)
2324
}
2425

2526
internal enum SDKFailableError: Error {

SuperTokensIOS/Classes/SuperTokens.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ public class SuperTokens {
4646
FrontToken.setItem(frontToken: "remove")
4747
}
4848

49-
public static func initialize(apiDomain: String, apiBasePath: String? = nil, sessionExpiredStatusCode: Int? = nil, sessionTokenBackendDomain: String? = nil, tokenTransferMethod: SuperTokensTokenTransferMethod? = nil, userDefaultsSuiteName: String? = nil, eventHandler: ((EventType) -> Void)? = nil, preAPIHook: ((APIAction, URLRequest) -> URLRequest)? = nil, postAPIHook: ((APIAction, URLRequest, URLResponse?) -> Void)? = nil) throws {
49+
public static func initialize(apiDomain: String, apiBasePath: String? = nil, sessionExpiredStatusCode: Int? = nil, sessionTokenBackendDomain: String? = nil, maxRetryAttemptsForSessionRefresh: Int? = nil, tokenTransferMethod: SuperTokensTokenTransferMethod? = nil, userDefaultsSuiteName: String? = nil, eventHandler: ((EventType) -> Void)? = nil, preAPIHook: ((APIAction, URLRequest) -> URLRequest)? = nil, postAPIHook: ((APIAction, URLRequest, URLResponse?) -> Void)? = nil) throws {
5050
if SuperTokens.isInitCalled {
5151
return;
5252
}
5353

54-
SuperTokens.config = try NormalisedInputType.normaliseInputType(apiDomain: apiDomain, apiBasePath: apiBasePath, sessionExpiredStatusCode: sessionExpiredStatusCode, sessionTokenBackendDomain: sessionTokenBackendDomain, tokenTransferMethod: tokenTransferMethod, eventHandler: eventHandler, preAPIHook: preAPIHook, postAPIHook: postAPIHook, userDefaultsSuiteName: userDefaultsSuiteName)
54+
SuperTokens.config = try NormalisedInputType.normaliseInputType(apiDomain: apiDomain, apiBasePath: apiBasePath, sessionExpiredStatusCode: sessionExpiredStatusCode, maxRetryAttemptsForSessionRefresh: maxRetryAttemptsForSessionRefresh, sessionTokenBackendDomain: sessionTokenBackendDomain, tokenTransferMethod: tokenTransferMethod, eventHandler: eventHandler, preAPIHook: preAPIHook, postAPIHook: postAPIHook, userDefaultsSuiteName: userDefaultsSuiteName)
5555

5656
guard let _config: NormalisedInputType = SuperTokens.config else {
5757
throw SuperTokensError.initError(message: "Error initialising SuperTokens")

SuperTokensIOS/Classes/SuperTokensURLProtocol.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Foundation
99

1010
public class SuperTokensURLProtocol: URLProtocol {
1111
private static let readWriteDispatchQueue = DispatchQueue(label: "io.supertokens.session.readwrite", attributes: .concurrent)
12+
private var sessionRefreshAttempts = 0
1213

1314
// Refer to comment in makeRequest to know why this is needed
1415
private var requestForRetry: NSMutableURLRequest? = nil
@@ -122,10 +123,24 @@ public class SuperTokensURLProtocol: URLProtocol {
122123
)
123124

124125
if httpResponse.statusCode == SuperTokens.config!.sessionExpiredStatusCode {
126+
/**
127+
* An API may return a 401 error response even with a valid session, causing a session refresh loop in the interceptor.
128+
* To prevent this infinite loop, we break out of the loop after retrying the original request a specified number of times.
129+
* The maximum number of retry attempts is defined by maxRetryAttemptsForSessionRefresh config variable.
130+
*/
131+
if self.sessionRefreshAttempts >= SuperTokens.config!.maxRetryAttemptsForSessionRefresh {
132+
let errorMessage = "Error: Received 401 response from \(String(describing: apiRequest.url)). After refreshing the session and retrying the request \(SuperTokens.config!.maxRetryAttemptsForSessionRefresh ) times, we still received 401 responses. Maximum session refresh limit reached. Breaking out of the refresh loop. Please investigate your API. Consider increasing maxRetryAttemptsForSessionRefresh in the config if needed."
133+
print(errorMessage)
134+
self.resolveToUser(data: nil, response: nil, error: SuperTokensError.maxRetryAttemptsReachedForSessionRefresh(message: errorMessage))
135+
return
136+
}
137+
125138
mutableRequest = self.removeAuthHeaderIfMatchesLocalToken(_mutableRequest: mutableRequest)
126139
SuperTokensURLProtocol.onUnauthorisedResponse(preRequestLocalSessionState: preRequestLocalSessionState, callback: {
127140
unauthResponse in
128141

142+
self.sessionRefreshAttempts += 1;
143+
129144
if unauthResponse.status == .RETRY {
130145
self.requestForRetry = mutableRequest
131146
self.makeRequest()

SuperTokensIOS/Classes/Utils.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,25 @@ class NormalisedInputType {
4343
var apiDomain: String
4444
var apiBasePath: String
4545
var sessionExpiredStatusCode: Int
46+
/**
47+
* This specifies the maximum number of times the interceptor will attempt to refresh
48+
* the session when a 401 Unauthorized response is received. If the number of retries
49+
* exceeds this limit, no further attempts will be made to refresh the session, and
50+
* and an error will be thrown.
51+
*/
52+
var maxRetryAttemptsForSessionRefresh: Int
4653
var sessionTokenBackendDomain: String?
4754
var eventHandler: (EventType) -> Void
4855
var preAPIHook: (APIAction, URLRequest) -> URLRequest
4956
var postAPIHook: (APIAction, URLRequest, URLResponse?) -> Void
5057
var userDefaultsSuiteName: String?
5158
var tokenTransferMethod: SuperTokensTokenTransferMethod
5259

53-
init(apiDomain: String, apiBasePath: String, sessionExpiredStatusCode: Int, sessionTokenBackendDomain: String?, tokenTransferMethod: SuperTokensTokenTransferMethod, eventHandler: @escaping (EventType) -> Void, preAPIHook: @escaping (APIAction, URLRequest) -> URLRequest, postAPIHook: @escaping (APIAction, URLRequest, URLResponse?) -> Void, userDefaultsSuiteName: String?) {
60+
init(apiDomain: String, apiBasePath: String, sessionExpiredStatusCode: Int, maxRetryAttemptsForSessionRefresh: Int, sessionTokenBackendDomain: String?, tokenTransferMethod: SuperTokensTokenTransferMethod, eventHandler: @escaping (EventType) -> Void, preAPIHook: @escaping (APIAction, URLRequest) -> URLRequest, postAPIHook: @escaping (APIAction, URLRequest, URLResponse?) -> Void, userDefaultsSuiteName: String?) {
5461
self.apiDomain = apiDomain
5562
self.apiBasePath = apiBasePath
5663
self.sessionExpiredStatusCode = sessionExpiredStatusCode
64+
self.maxRetryAttemptsForSessionRefresh = maxRetryAttemptsForSessionRefresh
5765
self.sessionTokenBackendDomain = sessionTokenBackendDomain
5866
self.eventHandler = eventHandler
5967
self.preAPIHook = preAPIHook
@@ -98,7 +106,7 @@ class NormalisedInputType {
98106
return noDotNormalised
99107
}
100108

101-
internal static func normaliseInputType(apiDomain: String, apiBasePath: String?, sessionExpiredStatusCode: Int?, sessionTokenBackendDomain: String?, tokenTransferMethod: SuperTokensTokenTransferMethod?, eventHandler: ((EventType) -> Void)?, preAPIHook: ((APIAction, URLRequest) -> URLRequest)?, postAPIHook: ((APIAction, URLRequest, URLResponse?) -> Void)?, userDefaultsSuiteName: String?) throws -> NormalisedInputType {
109+
internal static func normaliseInputType(apiDomain: String, apiBasePath: String?, sessionExpiredStatusCode: Int?, maxRetryAttemptsForSessionRefresh: Int? = nil, sessionTokenBackendDomain: String?, tokenTransferMethod: SuperTokensTokenTransferMethod?, eventHandler: ((EventType) -> Void)?, preAPIHook: ((APIAction, URLRequest) -> URLRequest)?, postAPIHook: ((APIAction, URLRequest, URLResponse?) -> Void)?, userDefaultsSuiteName: String?) throws -> NormalisedInputType {
102110
let _apiDomain = try NormalisedURLDomain(url: apiDomain)
103111
var _apiBasePath = try NormalisedURLPath(input: "/auth")
104112

@@ -111,6 +119,11 @@ class NormalisedInputType {
111119
_sessionExpiredStatusCode = sessionExpiredStatusCode!
112120
}
113121

122+
var _maxRetryAttemptsForSessionRefresh: Int = 10
123+
if maxRetryAttemptsForSessionRefresh != nil {
124+
_maxRetryAttemptsForSessionRefresh = maxRetryAttemptsForSessionRefresh!
125+
}
126+
114127
var _sessionTokenBackendDomain: String? = nil
115128
if sessionTokenBackendDomain != nil {
116129
_sessionTokenBackendDomain = try normaliseSessionScopeOrThrowError(sessionScope: sessionTokenBackendDomain!)
@@ -144,7 +157,7 @@ class NormalisedInputType {
144157
}
145158

146159

147-
return NormalisedInputType(apiDomain: _apiDomain.getAsStringDangerous(), apiBasePath: _apiBasePath.getAsStringDangerous(), sessionExpiredStatusCode: _sessionExpiredStatusCode, sessionTokenBackendDomain: _sessionTokenBackendDomain, tokenTransferMethod: _tokenTransferMethod, eventHandler: _eventHandler, preAPIHook: _preAPIHook, postAPIHook: _postApiHook, userDefaultsSuiteName: userDefaultsSuiteName)
160+
return NormalisedInputType(apiDomain: _apiDomain.getAsStringDangerous(), apiBasePath: _apiBasePath.getAsStringDangerous(), sessionExpiredStatusCode: _sessionExpiredStatusCode, maxRetryAttemptsForSessionRefresh: _maxRetryAttemptsForSessionRefresh, sessionTokenBackendDomain: _sessionTokenBackendDomain, tokenTransferMethod: _tokenTransferMethod, eventHandler: _eventHandler, preAPIHook: _preAPIHook, postAPIHook: _postApiHook, userDefaultsSuiteName: userDefaultsSuiteName)
148161
}
149162
}
150163

SuperTokensIOS/Classes/Version.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ import Foundation
99

1010
internal class Version {
1111
static let supported_fdi: [String] = ["1.16", "1.17", "1.18", "1.19", "2.0", "3.0"]
12-
static let sdkVersion = "0.3.2"
12+
static let sdkVersion = "0.4.0"
1313
}

testHelpers/server/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,10 @@ app.get("/testError", (req, res) => {
507507
res.status(500).send("test error message");
508508
});
509509

510+
app.get("/throw-401", (req, res) => {
511+
res.status(401).send("Unauthorised");
512+
});
513+
510514
app.get("/stop", async (req, res) => {
511515
process.exit();
512516
});

testHelpers/testapp/Tests/sessionTests.swift

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,4 +1260,191 @@ class sessionTests: XCTestCase {
12601260
//
12611261
// XCTAssertTrue(failureMessage == nil, failureMessage ?? "")
12621262
// }
1263+
1264+
func testBreakOutOfSessionRefreshLoopAfterDefaultMaxRetryAttempts() {
1265+
TestUtils.startST()
1266+
1267+
var failureMessage: String? = nil;
1268+
do {
1269+
try SuperTokens.initialize(apiDomain: testAPIBase, tokenTransferMethod: .cookie)
1270+
} catch {
1271+
failureMessage = "supertokens init failed"
1272+
}
1273+
1274+
let requestSemaphore = DispatchSemaphore(value: 0)
1275+
1276+
// Step 1: Login request
1277+
URLSession.shared.dataTask(with: TestUtils.getLoginRequest(), completionHandler: { data, response, error in
1278+
if error != nil {
1279+
failureMessage = "login API error"
1280+
requestSemaphore.signal()
1281+
return
1282+
}
1283+
1284+
if let httpResponse = response as? HTTPURLResponse {
1285+
if httpResponse.statusCode != 200 {
1286+
failureMessage = "Login response code is not 200";
1287+
requestSemaphore.signal()
1288+
} else {
1289+
let throw401URL = URL(string: "\(testAPIBase)/throw-401")!
1290+
var throw401Request = URLRequest(url: throw401URL)
1291+
throw401Request.httpMethod = "GET"
1292+
1293+
URLSession.shared.dataTask(with: throw401Request, completionHandler: { data, response, error in
1294+
if let error = error {
1295+
1296+
if (error as NSError).code != 4 {
1297+
failureMessage = "Expected the error code to be 4 (maxRetryAttemptsReachedForSessionRefresh)"
1298+
requestSemaphore.signal()
1299+
return;
1300+
}
1301+
1302+
1303+
let count = TestUtils.getRefreshTokenCounter()
1304+
if count != 10 {
1305+
failureMessage = "Expected refresh to be called 10 times but it was called " + String(count) + " times"
1306+
}
1307+
requestSemaphore.signal()
1308+
} else {
1309+
failureMessage = "Expected /throw-401 request to throw error"
1310+
requestSemaphore.signal()
1311+
}
1312+
}).resume()
1313+
}
1314+
} else {
1315+
failureMessage = "Login response is nil"
1316+
requestSemaphore.signal()
1317+
}
1318+
}).resume()
1319+
1320+
_ = requestSemaphore.wait(timeout: DispatchTime.distantFuture)
1321+
1322+
1323+
XCTAssertTrue(failureMessage == nil, failureMessage ?? "")
1324+
}
1325+
1326+
func testBreakOutOfSessionRefreshLoopAfterConfiguredMaxRetryAttempts() {
1327+
TestUtils.startST()
1328+
1329+
var failureMessage: String? = nil;
1330+
do {
1331+
try SuperTokens.initialize(apiDomain: testAPIBase, maxRetryAttemptsForSessionRefresh: 5, tokenTransferMethod: .cookie)
1332+
} catch {
1333+
failureMessage = "supertokens init failed"
1334+
}
1335+
1336+
let requestSemaphore = DispatchSemaphore(value: 0)
1337+
1338+
// Step 1: Login request
1339+
URLSession.shared.dataTask(with: TestUtils.getLoginRequest(), completionHandler: { data, response, error in
1340+
if error != nil {
1341+
failureMessage = "login API error"
1342+
requestSemaphore.signal()
1343+
return
1344+
}
1345+
1346+
if let httpResponse = response as? HTTPURLResponse {
1347+
if httpResponse.statusCode != 200 {
1348+
failureMessage = "Login response code is not 200";
1349+
requestSemaphore.signal()
1350+
} else {
1351+
let throw401URL = URL(string: "\(testAPIBase)/throw-401")!
1352+
var throw401Request = URLRequest(url: throw401URL)
1353+
throw401Request.httpMethod = "GET"
1354+
1355+
URLSession.shared.dataTask(with: throw401Request, completionHandler: { data, response, error in
1356+
if let error = error {
1357+
1358+
if (error as NSError).code != 4 {
1359+
failureMessage = "Expected the error code to be 4 (maxRetryAttemptsReachedForSessionRefresh)"
1360+
requestSemaphore.signal()
1361+
return;
1362+
}
1363+
1364+
1365+
let count = TestUtils.getRefreshTokenCounter()
1366+
if count != 5 {
1367+
failureMessage = "Expected refresh to be called 5 times but it was called " + String(count) + " times"
1368+
}
1369+
requestSemaphore.signal()
1370+
} else {
1371+
failureMessage = "Expected /throw-401 request to throw error"
1372+
requestSemaphore.signal()
1373+
}
1374+
}).resume()
1375+
}
1376+
} else {
1377+
failureMessage = "Login response is nil"
1378+
requestSemaphore.signal()
1379+
}
1380+
}).resume()
1381+
1382+
_ = requestSemaphore.wait(timeout: DispatchTime.distantFuture)
1383+
1384+
1385+
XCTAssertTrue(failureMessage == nil, failureMessage ?? "")
1386+
}
1387+
1388+
func testShouldNotDoSessionRefreshIfMaxRetryAttemptsForSessionRefreshIsZero() {
1389+
TestUtils.startST()
1390+
1391+
var failureMessage: String? = nil;
1392+
do {
1393+
try SuperTokens.initialize(apiDomain: testAPIBase, maxRetryAttemptsForSessionRefresh: 0, tokenTransferMethod: .cookie)
1394+
} catch {
1395+
failureMessage = "supertokens init failed"
1396+
}
1397+
1398+
let requestSemaphore = DispatchSemaphore(value: 0)
1399+
1400+
// Step 1: Login request
1401+
URLSession.shared.dataTask(with: TestUtils.getLoginRequest(), completionHandler: { data, response, error in
1402+
if error != nil {
1403+
failureMessage = "login API error"
1404+
requestSemaphore.signal()
1405+
return
1406+
}
1407+
1408+
if let httpResponse = response as? HTTPURLResponse {
1409+
if httpResponse.statusCode != 200 {
1410+
failureMessage = "Login response code is not 200";
1411+
requestSemaphore.signal()
1412+
} else {
1413+
let throw401URL = URL(string: "\(testAPIBase)/throw-401")!
1414+
var throw401Request = URLRequest(url: throw401URL)
1415+
throw401Request.httpMethod = "GET"
1416+
1417+
URLSession.shared.dataTask(with: throw401Request, completionHandler: { data, response, error in
1418+
if let error = error {
1419+
1420+
if (error as NSError).code != 4 {
1421+
failureMessage = "Expected the error code to be 4 (maxRetryAttemptsReachedForSessionRefresh)"
1422+
requestSemaphore.signal()
1423+
return;
1424+
}
1425+
1426+
1427+
let count = TestUtils.getRefreshTokenCounter()
1428+
if count != 0 {
1429+
failureMessage = "Expected refresh to be called 0 times but it was called " + String(count) + " times"
1430+
}
1431+
requestSemaphore.signal()
1432+
} else {
1433+
failureMessage = "Expected /throw-401 request to throw error"
1434+
requestSemaphore.signal()
1435+
}
1436+
}).resume()
1437+
}
1438+
} else {
1439+
failureMessage = "Login response is nil"
1440+
requestSemaphore.signal()
1441+
}
1442+
}).resume()
1443+
1444+
_ = requestSemaphore.wait(timeout: DispatchTime.distantFuture)
1445+
1446+
1447+
XCTAssertTrue(failureMessage == nil, failureMessage ?? "")
1448+
}
1449+
12631450
}

0 commit comments

Comments
 (0)