Skip to content

Commit c4ab616

Browse files
kurundakanshaaa19shijithkjayan
authored
centralize token renewal service (#3570)
* centralize token renewal service * renewal token is now used for renewal request * Fix test cases * Refactor error handling in renewAuthToken test for missing renewal token --------- Co-authored-by: Akansha Sakhre <[email protected]> Co-authored-by: Shijith Karumathil <[email protected]>
1 parent 9fbf32e commit c4ab616

File tree

3 files changed

+137
-68
lines changed

3 files changed

+137
-68
lines changed

src/services/AuthService.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,10 @@ describe('AuthService', () => {
115115
});
116116

117117
test('testing renewAuthToken with error when there is no auth token', async () => {
118-
// Call renewAuthToken
119-
const result = renewAuthToken();
120-
121-
expect(result).toBeInstanceOf(Error);
118+
clearAuthSession();
122119

120+
await expect(renewAuthToken()).rejects.toThrow('No renewal token available');
123121
// Verify setLogs was called with the correct arguments
124-
expect(setLogs).toHaveBeenCalledWith('Token renewal failed: not found', 'error');
122+
expect(setLogs).toHaveBeenCalledWith('Token renewal failed: renewal_token not found', 'error');
125123
});
126124
});

src/services/AuthService.tsx

Lines changed: 37 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import axios from 'axios';
22
import { setErrorMessage } from 'common/notification';
33

4-
import { RENEW_TOKEN, VITE_GLIFIC_AUTHENTICATION_API } from 'config';
4+
import { VITE_GLIFIC_AUTHENTICATION_API } from 'config';
55
import setLogs from 'config/logs';
6+
import { tokenRenewalManager } from './TokenRenewalService';
67

78
interface RegisterRequest {
89
phone: string;
@@ -51,40 +52,34 @@ export const getAuthSession = (element?: string) => {
5152
};
5253

5354
// service to auto renew the auth token based on valid refresh token
54-
export const renewAuthToken = () => {
55-
const renewalToken = getAuthSession('renewal_token');
56-
if (!renewalToken) {
57-
setLogs('Token renewal failed: not found', 'error');
58-
return new Error('Error');
59-
}
60-
// get the renewal token from session
61-
axios.defaults.headers.common.authorization = renewalToken;
62-
63-
return axios
64-
.post(RENEW_TOKEN)
65-
.then((response: any) => response)
66-
.catch((error: any) => {
67-
// if we are not able to renew the token for some weird reason or if refresh token
68-
throw error;
69-
});
70-
};
55+
// delegates this to TokenRenewalManager to prevent race conditions
56+
export const renewAuthToken = () => tokenRenewalManager.renewToken();
7157

7258
// service to check the validity of the auth / token status
59+
// uses a 30sec buffer to proactively renew tokens before they expire
60+
// this prevents mid request token expirations
7361
export const checkAuthStatusService = () => {
7462
let authStatus = false;
7563
const tokenExpiryTimeFromSession = getAuthSession('token_expiry_time');
64+
7665
// return false if there is no session on local
7766
if (!tokenExpiryTimeFromSession) {
7867
authStatus = false;
7968
} else {
8069
const tokenExpiryTime = new Date(tokenExpiryTimeFromSession);
81-
// compare the session token with the current time
82-
if (tokenExpiryTime > new Date()) {
83-
// token is still valid return true
70+
const now = new Date();
71+
const bufferMs = 30 * 1000; // 30 seconds buffer
72+
73+
// consider token invalid if it expires within the next 30 seconds
74+
// this ensures we renew tokens proactively before they actually expire
75+
if (tokenExpiryTime.getTime() - now.getTime() > bufferMs) {
76+
// token is still valid (with buffer) return true
8477
authStatus = true;
78+
setLogs(`Token valid. Expires in ${Math.floor((tokenExpiryTime.getTime() - now.getTime()) / 1000)}s`, 'info');
8579
} else {
86-
// this means token has expired and let's return false
80+
// this means token has expired or is about to expire
8781
authStatus = false;
82+
setLogs('Token expired or expiring soon (within 30s), renewal required', 'info');
8883
}
8984
}
9085
return authStatus;
@@ -192,37 +187,24 @@ export const getOrganizationServices = (service: ServiceType) => {
192187

193188
export const setAuthHeaders = () => {
194189
// add authorization header in all calls
195-
let renewTokenCalled = false;
196-
let renewCallInProgress = false;
197-
198190
const { fetch } = window;
199191
window.fetch = (...args) =>
200192
(async (parameters) => {
201193
const parametersCopy = parameters;
202-
if (checkAuthStatusService()) {
203-
if (parametersCopy[1]) {
204-
parametersCopy[1].headers = {
205-
...parametersCopy[1].headers,
206-
authorization: getAuthSession('access_token'),
207-
};
208-
}
209-
// @ts-ignore
210-
const result = await fetch(...parametersCopy);
211-
return result;
212-
}
213-
renewTokenCalled = true;
214-
const authToken = await renewAuthToken();
215-
if (authToken.data) {
216-
// update localstore
217-
setAuthSession(authToken.data.data);
218-
renewTokenCalled = false;
194+
195+
// check if token is valid (or renew if needed)
196+
if (!checkAuthStatusService()) {
197+
setLogs('Fetch: Token invalid, triggering renewal', 'info');
198+
await renewAuthToken();
219199
}
200+
220201
if (parametersCopy[1]) {
221202
parametersCopy[1].headers = {
222203
...parametersCopy[1].headers,
223204
authorization: getAuthSession('access_token'),
224205
};
225206
}
207+
226208
// @ts-ignore
227209
const result = await fetch(...parametersCopy);
228210
return result;
@@ -240,6 +222,7 @@ export const setAuthHeaders = () => {
240222
username?: string | null,
241223
password?: string | null
242224
) {
225+
// mark renewal endpoint calls to prevent infinite loops
243226
if (url.toString().endsWith('renew')) {
244227
// @ts-ignore
245228
this.renewGlificCall = true;
@@ -251,35 +234,26 @@ export const setAuthHeaders = () => {
251234
((send) => {
252235
XMLHttpRequest.prototype.send = async function authCheck(body) {
253236
this.addEventListener('loadend', () => {
237+
// handle 401 errors by logging out
254238
// @ts-ignore
255-
if (this.renewGlificCall) {
256-
renewCallInProgress = false;
257-
} else if (this.status === 401) {
239+
if (!this.renewGlificCall && this.status === 401) {
240+
setLogs('XMLHttpRequest: Received 401, logging out', 'error');
258241
window.location.href = '/logout/user';
259242
}
260243
});
261244

262245
// @ts-ignore
263-
if (this.renewGlificCall && !renewCallInProgress) {
264-
renewCallInProgress = true;
246+
if (this.renewGlificCall) {
247+
// this is the renewal endpoint itself - don't add auth or trigger renewal
248+
// to prevent infinite loops
265249
send.call(this, body);
266-
}
267-
// @ts-ignore
268-
else if (this.renewGlificCall) {
269-
this.abort();
270-
}
271-
// @ts-ignore
272-
else if (checkAuthStatusService()) {
273-
this.setRequestHeader('authorization', getAuthSession('access_token'));
274-
send.call(this, body);
275-
} else if (!renewTokenCalled) {
276-
renewTokenCalled = true;
277-
const authToken = await renewAuthToken();
278-
if (authToken.data) {
279-
// update localstore
280-
setAuthSession(authToken.data.data);
281-
renewTokenCalled = false;
250+
} else {
251+
// regular request - check token and renew if needed
252+
if (!checkAuthStatusService()) {
253+
setLogs('XMLHttpRequest: Token invalid, triggering renewal', 'info');
254+
await renewAuthToken();
282255
}
256+
283257
this.setRequestHeader('authorization', getAuthSession('access_token'));
284258
send.call(this, body);
285259
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import axios from 'axios';
2+
import { RENEW_TOKEN } from 'config';
3+
import setLogs from 'config/logs';
4+
5+
/**
6+
* TokenRenewalManager
7+
*
8+
* This is centralized manager for authentication token renewal that prevents race conditions
9+
* between multiple renewal requests done by various clients. (GraphQL, Axios)
10+
*
11+
*/
12+
class TokenRenewalManager {
13+
private renewalPromise: Promise<any> | null = null;
14+
15+
/**
16+
* Renew authentication token
17+
*
18+
* If a renewal is already in progress then it returns the exsiting promise.
19+
* Otherwise start a new renewal and returns it's promise.
20+
*
21+
* This ensures only one token renewal happens at a time, regardless of
22+
* how many simultaneous requests trigger renewal.
23+
*
24+
* @returns Promise that resolves with the renewal response
25+
*/
26+
async renewToken(): Promise<any> {
27+
// if renewal already in progress then return existing promise
28+
if (this.renewalPromise) {
29+
setLogs('Token renewal: Waiting for existing renewal to complete', 'info');
30+
return this.renewalPromise;
31+
}
32+
33+
setLogs('Token renewal: Starting new renewal', 'info');
34+
35+
this.renewalPromise = this.performRenewal();
36+
37+
try {
38+
const result = await this.renewalPromise;
39+
setLogs('Token renewal: Completed successfully', 'info');
40+
return result;
41+
} catch (error) {
42+
setLogs(`Token renewal: Failed - ${error}`, 'error');
43+
throw error;
44+
} finally {
45+
// clear promise after completion (success or failure)
46+
this.renewalPromise = null;
47+
}
48+
}
49+
50+
/**
51+
* Perform the actual token renewal API call
52+
*
53+
* @private
54+
* @returns Promise with axios response
55+
* @throws Error if renewal token is missing or API call fails
56+
*/
57+
private async performRenewal(): Promise<any> {
58+
// TODO: Not sure if this is the best way to handle circular dependencies
59+
// import here to avoid circular dependency
60+
const { getAuthSession, setAuthSession } = await import('./AuthService');
61+
62+
const renewalToken = getAuthSession('renewal_token');
63+
if (!renewalToken) {
64+
setLogs('Token renewal failed: renewal_token not found', 'error');
65+
throw new Error('No renewal token available');
66+
}
67+
68+
try {
69+
const response = await axios.post(RENEW_TOKEN, null, {
70+
headers: { authorization: renewalToken },
71+
});
72+
73+
// update session with new tokens
74+
if (response.data && response.data.data) {
75+
setAuthSession(response.data.data);
76+
setLogs('Token renewal: Session updated with new tokens', 'info');
77+
}
78+
79+
return response;
80+
} catch (error: any) {
81+
setLogs(`Token renewal API error: ${error.message}`, 'error');
82+
throw error;
83+
}
84+
}
85+
86+
/**
87+
* Check if a renewal is currently in progress
88+
*
89+
* @returns true if renewal is in progress, false otherwise
90+
*/
91+
isRenewalInProgress(): boolean {
92+
return this.renewalPromise !== null;
93+
}
94+
}
95+
96+
// export singleton instance
97+
export const tokenRenewalManager = new TokenRenewalManager();

0 commit comments

Comments
 (0)