diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d746c8..92d48ca 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] +## [5.1.0] - 2024-06-04 + +### 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. + ## [5.0.2] - 2024-05-28 - Adds FDI 2.0 and 3.0 to the list of supported FDI versions diff --git a/lib/build/axios.d.ts b/lib/build/axios.d.ts index 22b7f77..d016035 100644 --- a/lib/build/axios.d.ts +++ b/lib/build/axios.d.ts @@ -1,4 +1,8 @@ -import { AxiosPromise, AxiosRequestConfig, AxiosResponse } from "axios"; +import { AxiosPromise, AxiosRequestConfig as OriginalAxiosRequestConfig, AxiosResponse } from "axios"; +declare type AxiosRequestConfig = OriginalAxiosRequestConfig & { + __supertokensSessionRefreshAttempts?: number; + __supertokensAddedAuthHeader?: boolean; +}; export declare function interceptorFunctionRequestFulfilled(config: AxiosRequestConfig): Promise; export declare function responseInterceptor(axiosInstance: any): (response: AxiosResponse) => Promise>; export declare function responseErrorInterceptor(axiosInstance: any): (error: any) => Promise>; @@ -15,3 +19,4 @@ export default class AuthHttpRequest { */ static doRequest: (httpCall: (config: AxiosRequestConfig) => AxiosPromise, config: AxiosRequestConfig, url?: string | undefined, prevResponse?: AxiosResponse | undefined, prevError?: any, viaInterceptor?: boolean) => Promise>; } +export {}; diff --git a/lib/build/axios.js b/lib/build/axios.js index e7c0061..f0c7274 100644 --- a/lib/build/axios.js +++ b/lib/build/axios.js @@ -29,13 +29,25 @@ var __awaiter = step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; -import { createAxiosErrorFromAxiosResp, createAxiosErrorFromFetchResp } from "./axiosError"; +import { createAxiosErrorFromFetchResp } from "./axiosError"; import AuthHttpRequestFetch, { onUnauthorisedResponse } from "./fetch"; import FrontToken from "./frontToken"; import AntiCSRF from "./antiCsrf"; import { PROCESS_STATE, ProcessState } from "./processState"; import { fireSessionUpdateEventsIfNecessary, getLocalSessionState, getTokenForHeaderAuth, setToken } from "./utils"; import { logDebugMessage } from "./logger"; +function incrementSessionRefreshAttemptCount(config) { + if (config.__supertokensSessionRefreshAttempts === undefined) { + config.__supertokensSessionRefreshAttempts = 0; + } + config.__supertokensSessionRefreshAttempts++; +} +function hasExceededMaxSessionRefreshAttempts(config) { + if (config.__supertokensSessionRefreshAttempts === undefined) { + config.__supertokensSessionRefreshAttempts = 0; + } + return config.__supertokensSessionRefreshAttempts >= AuthHttpRequestFetch.config.maxRetryAttemptsForSessionRefresh; +} function getUrlFromConfig(config) { let url = config.url === undefined ? "" : config.url; let baseURL = config.baseURL; @@ -333,20 +345,7 @@ AuthHttpRequest.doRequest = (httpCall, config, url, prevResponse, prevError, via response.status, response.headers["front-token"] ); - if (response.status === AuthHttpRequestFetch.config.sessionExpiredStatusCode) { - logDebugMessage("doRequest: Status code is: " + response.status); - const refreshResult = yield onUnauthorisedResponse(preRequestLocalSessionState); - if (refreshResult.result !== "RETRY") { - logDebugMessage("doRequest: Not retrying original request"); - returnObj = refreshResult.error - ? yield createAxiosErrorFromFetchResp(refreshResult.error) - : yield createAxiosErrorFromAxiosResp(response); - break; - } - logDebugMessage("doRequest: Retrying original request"); - } else { - return response; - } + return response; } catch (err) { const response = err.response; if (response !== undefined) { @@ -358,7 +357,24 @@ AuthHttpRequest.doRequest = (httpCall, config, url, prevResponse, prevError, via ); if (err.response.status === AuthHttpRequestFetch.config.sessionExpiredStatusCode) { logDebugMessage("doRequest: Status code is: " + response.status); + /** + * 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 (hasExceededMaxSessionRefreshAttempts(config)) { + logDebugMessage( + `doRequest: Maximum session refresh attempts reached. sessionRefreshAttempts: ${config.__supertokensSessionRefreshAttempts}, maxRetryAttemptsForSessionRefresh: ${AuthHttpRequestFetch.config.maxRetryAttemptsForSessionRefresh}` + ); + throw new Error( + `Received a 401 response from ${url}. Attempted to refresh the session and retry the request with the updated session tokens ${AuthHttpRequestFetch.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.` + ); + } const refreshResult = yield onUnauthorisedResponse(preRequestLocalSessionState); + incrementSessionRefreshAttemptCount(config); + logDebugMessage( + "doRequest: sessionRefreshAttempts: " + config.__supertokensSessionRefreshAttempts + ); if (refreshResult.result !== "RETRY") { logDebugMessage("doRequest: Not retrying original request"); // Returning refreshResult.error as an Axios Error if we attempted a refresh diff --git a/lib/build/axiosError.d.ts b/lib/build/axiosError.d.ts index 3118a7e..625d61f 100644 --- a/lib/build/axiosError.d.ts +++ b/lib/build/axiosError.d.ts @@ -1,3 +1,2 @@ -import { AxiosError, AxiosResponse } from "axios"; +import { AxiosError } from "axios"; export declare function createAxiosErrorFromFetchResp(response: Response): Promise; -export declare function createAxiosErrorFromAxiosResp(response: AxiosResponse): Promise; diff --git a/lib/build/axiosError.js b/lib/build/axiosError.js index e59ff20..66080c1 100644 --- a/lib/build/axiosError.js +++ b/lib/build/axiosError.js @@ -104,14 +104,3 @@ export function createAxiosErrorFromFetchResp(response) { ); }); } -export function createAxiosErrorFromAxiosResp(response) { - return __awaiter(this, void 0, void 0, function*() { - return enhanceAxiosError( - new Error("Request failed with status code " + response.status), - response.config, - undefined, - response.request, - response - ); - }); -} diff --git a/lib/build/fetch.js b/lib/build/fetch.js index 8c01e2e..8130aa1 100644 --- a/lib/build/fetch.js +++ b/lib/build/fetch.js @@ -164,6 +164,7 @@ AuthHttpRequest.doRequest = (httpCall, config, url) => logDebugMessage("doRequest: Interception started"); ProcessState.getInstance().addState(PROCESS_STATE.CALLING_INTERCEPTION_REQUEST); try { + let sessionRefreshAttempts = 0; let returnObj = undefined; while (true) { // we read this here so that if there is a session expiry error, then we can compare this value (that caused the error) with the value after the request is sent. @@ -212,7 +213,22 @@ AuthHttpRequest.doRequest = (httpCall, config, url) => ); if (response.status === AuthHttpRequest.config.sessionExpiredStatusCode) { logDebugMessage("doRequest: Status code is: " + response.status); + /** + * 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 (sessionRefreshAttempts >= AuthHttpRequest.config.maxRetryAttemptsForSessionRefresh) { + logDebugMessage( + `doRequest: Maximum session refresh attempts reached. sessionRefreshAttempts: ${sessionRefreshAttempts}, maxRetryAttemptsForSessionRefresh: ${AuthHttpRequest.config.maxRetryAttemptsForSessionRefresh}` + ); + throw new Error( + `Received a 401 response from ${url}. Attempted to refresh the session and retry the request with the updated session tokens ${AuthHttpRequest.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.` + ); + } let refreshResponse = yield onUnauthorisedResponse(preRequestLocalSessionState); + sessionRefreshAttempts++; + logDebugMessage("doRequest: sessionRefreshAttempts: " + sessionRefreshAttempts); if (refreshResponse.result !== "RETRY") { logDebugMessage("doRequest: Not retrying original request"); returnObj = refreshResponse.error !== undefined ? refreshResponse.error : response; diff --git a/lib/build/types.d.ts b/lib/build/types.d.ts index 40547a2..2128cb7 100644 --- a/lib/build/types.d.ts +++ b/lib/build/types.d.ts @@ -13,6 +13,13 @@ export declare type InputType = { sessionExpiredStatusCode?: number; autoAddCredentials?: boolean; tokenTransferMethod?: "cookie" | "header"; + /** + * 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 + * the last response will be returned to the caller. + */ + maxRetryAttemptsForSessionRefresh?: number; sessionTokenBackendDomain?: string; preAPIHook?: (context: { action: "SIGN_OUT" | "REFRESH_SESSION"; @@ -33,6 +40,7 @@ export declare type NormalisedInputType = { sessionExpiredStatusCode: number; autoAddCredentials: boolean; tokenTransferMethod: string; + maxRetryAttemptsForSessionRefresh: number; sessionTokenBackendDomain: string | undefined; preAPIHook: (context: { action: "SIGN_OUT" | "REFRESH_SESSION"; diff --git a/lib/build/utils.js b/lib/build/utils.js index 139c354..be1d678 100644 --- a/lib/build/utils.js +++ b/lib/build/utils.js @@ -102,6 +102,13 @@ export function validateAndNormaliseInputOrThrowError(options) { if (options.sessionTokenBackendDomain !== undefined) { sessionTokenBackendDomain = normaliseSessionScopeOrThrowError(options.sessionTokenBackendDomain); } + let maxRetryAttemptsForSessionRefresh = 10; + if (options.maxRetryAttemptsForSessionRefresh !== undefined) { + if (options.maxRetryAttemptsForSessionRefresh < 0) { + throw new Error("maxRetryAttemptsForSessionRefresh must be greater than or equal to 0."); + } + maxRetryAttemptsForSessionRefresh = options.maxRetryAttemptsForSessionRefresh; + } let preAPIHook = context => __awaiter(this, void 0, void 0, function*() { return { url: context.url, requestInit: context.requestInit }; @@ -120,6 +127,7 @@ export function validateAndNormaliseInputOrThrowError(options) { apiBasePath, sessionExpiredStatusCode, autoAddCredentials, + maxRetryAttemptsForSessionRefresh, sessionTokenBackendDomain, tokenTransferMethod, preAPIHook, diff --git a/lib/build/version.d.ts b/lib/build/version.d.ts index a0b3632..5c73ca8 100644 --- a/lib/build/version.d.ts +++ b/lib/build/version.d.ts @@ -1,2 +1,2 @@ -export declare const package_version = "5.0.2"; +export declare const package_version = "5.1.0"; export declare const supported_fdi: string[]; diff --git a/lib/build/version.js b/lib/build/version.js index 4652988..10fa84e 100644 --- a/lib/build/version.js +++ b/lib/build/version.js @@ -12,5 +12,5 @@ * License for the specific language governing permissions and limitations * under the License. */ -export const package_version = "5.0.2"; +export const package_version = "5.1.0"; export const supported_fdi = ["1.16", "1.17", "1.18", "1.19", "2.0", "3.0"]; diff --git a/lib/ts/axios.ts b/lib/ts/axios.ts index 0f8492c..4b5d780 100644 --- a/lib/ts/axios.ts +++ b/lib/ts/axios.ts @@ -12,8 +12,8 @@ * License for the specific language governing permissions and limitations * under the License. */ -import { AxiosPromise, AxiosRequestConfig, AxiosResponse } from "axios"; -import { createAxiosErrorFromAxiosResp, createAxiosErrorFromFetchResp } from "./axiosError"; +import { AxiosPromise, AxiosRequestConfig as OriginalAxiosRequestConfig, AxiosResponse } from "axios"; +import { createAxiosErrorFromFetchResp } from "./axiosError"; import AuthHttpRequestFetch, { onUnauthorisedResponse } from "./fetch"; @@ -23,6 +23,26 @@ import { PROCESS_STATE, ProcessState } from "./processState"; import { fireSessionUpdateEventsIfNecessary, getLocalSessionState, getTokenForHeaderAuth, setToken } from "./utils"; import { logDebugMessage } from "./logger"; +type AxiosRequestConfig = OriginalAxiosRequestConfig & { + __supertokensSessionRefreshAttempts?: number; + __supertokensAddedAuthHeader?: boolean; +}; + +function incrementSessionRefreshAttemptCount(config: AxiosRequestConfig) { + if (config.__supertokensSessionRefreshAttempts === undefined) { + config.__supertokensSessionRefreshAttempts = 0; + } + config.__supertokensSessionRefreshAttempts++; +} + +function hasExceededMaxSessionRefreshAttempts(config: AxiosRequestConfig): boolean { + if (config.__supertokensSessionRefreshAttempts === undefined) { + config.__supertokensSessionRefreshAttempts = 0; + } + + return config.__supertokensSessionRefreshAttempts >= AuthHttpRequestFetch.config.maxRetryAttemptsForSessionRefresh; +} + function getUrlFromConfig(config: AxiosRequestConfig) { let url: string = config.url === undefined ? "" : config.url; let baseURL: string | undefined = config.baseURL; @@ -373,21 +393,7 @@ export default class AuthHttpRequest { response.headers["front-token"] ); - if (response.status === AuthHttpRequestFetch.config.sessionExpiredStatusCode) { - logDebugMessage("doRequest: Status code is: " + response.status); - const refreshResult = await onUnauthorisedResponse(preRequestLocalSessionState); - - if (refreshResult.result !== "RETRY") { - logDebugMessage("doRequest: Not retrying original request"); - returnObj = refreshResult.error - ? await createAxiosErrorFromFetchResp(refreshResult.error) - : await createAxiosErrorFromAxiosResp(response); - break; - } - logDebugMessage("doRequest: Retrying original request"); - } else { - return response; - } + return response; } catch (err) { const response = (err as any).response; if (response !== undefined) { @@ -401,7 +407,26 @@ export default class AuthHttpRequest { if (err.response.status === AuthHttpRequestFetch.config.sessionExpiredStatusCode) { logDebugMessage("doRequest: Status code is: " + response.status); + + /** + * 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 (hasExceededMaxSessionRefreshAttempts(config)) { + logDebugMessage( + `doRequest: Maximum session refresh attempts reached. sessionRefreshAttempts: ${config.__supertokensSessionRefreshAttempts}, maxRetryAttemptsForSessionRefresh: ${AuthHttpRequestFetch.config.maxRetryAttemptsForSessionRefresh}` + ); + throw new Error( + `Received a 401 response from ${url}. Attempted to refresh the session and retry the request with the updated session tokens ${AuthHttpRequestFetch.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.` + ); + } + const refreshResult = await onUnauthorisedResponse(preRequestLocalSessionState); + incrementSessionRefreshAttemptCount(config); + logDebugMessage( + "doRequest: sessionRefreshAttempts: " + config.__supertokensSessionRefreshAttempts + ); if (refreshResult.result !== "RETRY") { logDebugMessage("doRequest: Not retrying original request"); // Returning refreshResult.error as an Axios Error if we attempted a refresh diff --git a/lib/ts/axiosError.ts b/lib/ts/axiosError.ts index de5e3c7..b74e2c9 100644 --- a/lib/ts/axiosError.ts +++ b/lib/ts/axiosError.ts @@ -97,13 +97,3 @@ export async function createAxiosErrorFromFetchResp(response: Response): Promise axiosResponse ); } - -export async function createAxiosErrorFromAxiosResp(response: AxiosResponse): Promise { - return enhanceAxiosError( - new Error("Request failed with status code " + response.status), - response.config, - undefined, - response.request, - response - ); -} diff --git a/lib/ts/fetch.ts b/lib/ts/fetch.ts index 85ccfe3..fb67070 100644 --- a/lib/ts/fetch.ts +++ b/lib/ts/fetch.ts @@ -163,6 +163,7 @@ export default class AuthHttpRequest { ProcessState.getInstance().addState(PROCESS_STATE.CALLING_INTERCEPTION_REQUEST); try { + let sessionRefreshAttempts = 0; let returnObj = undefined; while (true) { // we read this here so that if there is a session expiry error, then we can compare this value (that caused the error) with the value after the request is sent. @@ -225,7 +226,26 @@ export default class AuthHttpRequest { if (response.status === AuthHttpRequest.config.sessionExpiredStatusCode) { logDebugMessage("doRequest: Status code is: " + response.status); + + /** + * 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 (sessionRefreshAttempts >= AuthHttpRequest.config.maxRetryAttemptsForSessionRefresh) { + logDebugMessage( + `doRequest: Maximum session refresh attempts reached. sessionRefreshAttempts: ${sessionRefreshAttempts}, maxRetryAttemptsForSessionRefresh: ${AuthHttpRequest.config.maxRetryAttemptsForSessionRefresh}` + ); + throw new Error( + `Received a 401 response from ${url}. Attempted to refresh the session and retry the request with the updated session tokens ${AuthHttpRequest.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.` + ); + } + let refreshResponse = await onUnauthorisedResponse(preRequestLocalSessionState); + + sessionRefreshAttempts++; + logDebugMessage("doRequest: sessionRefreshAttempts: " + sessionRefreshAttempts); + if (refreshResponse.result !== "RETRY") { logDebugMessage("doRequest: Not retrying original request"); returnObj = refreshResponse.error !== undefined ? refreshResponse.error : response; diff --git a/lib/ts/types.ts b/lib/ts/types.ts index 761f071..7c6ee75 100644 --- a/lib/ts/types.ts +++ b/lib/ts/types.ts @@ -33,6 +33,13 @@ export type InputType = { sessionExpiredStatusCode?: number; autoAddCredentials?: boolean; tokenTransferMethod?: "cookie" | "header"; + /** + * 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 + * the last response will be returned to the caller. + */ + maxRetryAttemptsForSessionRefresh?: number; sessionTokenBackendDomain?: string; preAPIHook?: (context: { action: "SIGN_OUT" | "REFRESH_SESSION"; @@ -54,6 +61,7 @@ export type NormalisedInputType = { sessionExpiredStatusCode: number; autoAddCredentials: boolean; tokenTransferMethod: string; + maxRetryAttemptsForSessionRefresh: number; sessionTokenBackendDomain: string | undefined; preAPIHook: (context: { action: "SIGN_OUT" | "REFRESH_SESSION"; diff --git a/lib/ts/utils.ts b/lib/ts/utils.ts index 92b473b..6271ac5 100644 --- a/lib/ts/utils.ts +++ b/lib/ts/utils.ts @@ -92,6 +92,14 @@ export function validateAndNormaliseInputOrThrowError(options: InputType): Norma sessionTokenBackendDomain = normaliseSessionScopeOrThrowError(options.sessionTokenBackendDomain); } + let maxRetryAttemptsForSessionRefresh = 10; + if (options.maxRetryAttemptsForSessionRefresh !== undefined) { + if (options.maxRetryAttemptsForSessionRefresh < 0) { + throw new Error("maxRetryAttemptsForSessionRefresh must be greater than or equal to 0."); + } + maxRetryAttemptsForSessionRefresh = options.maxRetryAttemptsForSessionRefresh; + } + let preAPIHook = async (context: { action: "SIGN_OUT" | "REFRESH_SESSION"; requestInit: RequestInit; @@ -122,6 +130,7 @@ export function validateAndNormaliseInputOrThrowError(options: InputType): Norma apiBasePath, sessionExpiredStatusCode, autoAddCredentials, + maxRetryAttemptsForSessionRefresh, sessionTokenBackendDomain, tokenTransferMethod, preAPIHook, diff --git a/lib/ts/version.ts b/lib/ts/version.ts index e3941b7..5546fba 100644 --- a/lib/ts/version.ts +++ b/lib/ts/version.ts @@ -12,6 +12,6 @@ * License for the specific language governing permissions and limitations * under the License. */ -export const package_version = "5.0.2"; +export const package_version = "5.1.0"; export const supported_fdi = ["1.16", "1.17", "1.18", "1.19", "2.0", "3.0"]; diff --git a/package-lock.json b/package-lock.json index 10228c2..a2877f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supertokens-react-native", - "version": "5.0.2", + "version": "5.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "supertokens-react-native", - "version": "5.0.2", + "version": "5.1.0", "license": "Apache 2.0", "dependencies": { "base-64": "^1.0.0", diff --git a/package.json b/package.json index 14a7119..5bf943e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "supertokens-react-native", - "version": "5.0.2", + "version": "5.1.0", "description": "React Native SDK for SuperTokens", "main": "index.js", "scripts": {