Skip to content

Commit 1ba7a87

Browse files
Refactor OAuth token refresh logic in AuthHandler to prevent concurrent refresh operations. Introduced oauthRefreshInFlight promise to manage refresh state and updated tests to validate new behavior.
1 parent f7edbd3 commit 1ba7a87

2 files changed

Lines changed: 81 additions & 52 deletions

File tree

packages/contentstack-utilities/src/auth-handler.ts

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ class AuthHandler {
3535
private allAuthConfigItems: any;
3636
private oauthHandler: any;
3737
private managementAPIClient: ContentstackClient;
38+
/** True while an OAuth access-token refresh is running (for logging/diagnostics; correctness uses `oauthRefreshInFlight`). */
3839
private isRefreshingToken: boolean = false; // Flag to track if a refresh operation is in progress
40+
/** Serialize OAuth refresh so concurrent API calls await the same refresh instead of proceeding with a stale token. */
41+
private oauthRefreshInFlight: Promise<void> | null = null;
3942
private cmaHost: string;
4043

4144
set host(contentStackHost) {
@@ -376,42 +379,42 @@ class AuthHandler {
376379
checkExpiryAndRefresh = (force: boolean = false) => this.compareOAuthExpiry(force);
377380

378381
async compareOAuthExpiry(force: boolean = false) {
379-
// Avoid recursive refresh operations
380-
if (this.isRefreshingToken) {
381-
cliux.print('Refresh operation already in progress');
382-
return Promise.resolve();
383-
}
384382
const oauthDateTime = configHandler.get(this.oauthDateTimeKeyName);
385383
const authorisationType = configHandler.get(this.authorisationTypeKeyName);
386384
if (oauthDateTime && authorisationType === this.authorisationTypeOAUTHValue) {
387385
const now = new Date();
388386
const oauthDate = new Date(oauthDateTime);
389-
const oauthValidUpto = new Date();
390-
oauthValidUpto.setTime(oauthDate.getTime() + 59 * 60 * 1000);
391-
if (force) {
392-
cliux.print('Forcing token refresh...');
393-
return this.refreshToken();
394-
} else {
395-
if (oauthValidUpto > now) {
396-
return Promise.resolve();
397-
} else {
398-
cliux.print('Token expired, refreshing the token');
399-
// Set the flag before refreshing the token
400-
this.isRefreshingToken = true;
401-
402-
try {
403-
await this.refreshToken();
404-
} catch (error) {
405-
cliux.error('Error refreshing token');
406-
throw error;
407-
} finally {
408-
// Reset the flag after refresh operation is completed
409-
this.isRefreshingToken = false;
410-
}
387+
const oauthValidUpto = new Date(oauthDate.getTime() + 59 * 60 * 1000);
388+
const tokenExpired = oauthValidUpto <= now;
389+
const shouldRefresh = force || tokenExpired;
411390

412-
return Promise.resolve();
413-
}
391+
if (!shouldRefresh) {
392+
return Promise.resolve();
393+
}
394+
395+
if (this.oauthRefreshInFlight) {
396+
return this.oauthRefreshInFlight;
414397
}
398+
399+
this.isRefreshingToken = true;
400+
this.oauthRefreshInFlight = (async () => {
401+
try {
402+
if (force) {
403+
cliux.print('Forcing token refresh...');
404+
} else {
405+
cliux.print('Token expired, refreshing the token');
406+
}
407+
await this.refreshToken();
408+
} catch (error) {
409+
cliux.error('Error refreshing token');
410+
throw error;
411+
} finally {
412+
this.isRefreshingToken = false;
413+
this.oauthRefreshInFlight = null;
414+
}
415+
})();
416+
417+
return this.oauthRefreshInFlight;
415418
} else {
416419
cliux.print('No OAuth configuration set.');
417420
this.unsetConfigData();

packages/contentstack-utilities/test/unit/auth-handler.test.ts

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,8 @@ describe('Auth Handler', () => {
456456

457457
beforeEach(() => {
458458
sandbox = createSandbox();
459+
authHandler.oauthRefreshInFlight = null;
460+
authHandler.isRefreshingToken = false;
459461
configHandlerGetStub = sandbox.stub(configHandler, 'get');
460462
cliuxPrintStub = sandbox.stub(cliux, 'print');
461463
refreshTokenStub = sandbox.stub(authHandler, 'refreshToken').resolves();
@@ -467,40 +469,64 @@ describe('Auth Handler', () => {
467469
});
468470

469471
it('should resolve if the OAuth token is valid and not expired', async () => {
470-
const expectedOAuthDateTime = '2023-05-30T12:00:00Z';
471-
const expectedAuthorisationType = 'oauth';
472-
const now = new Date('2023-05-30T12:30:00Z');
472+
const expectedOAuthDateTime = new Date(Date.now() - 30 * 60 * 1000).toISOString();
473+
const expectedAuthorisationType = 'OAUTH';
473474

474475
configHandlerGetStub.withArgs(authHandler.oauthDateTimeKeyName).returns(expectedOAuthDateTime);
475476
configHandlerGetStub.withArgs(authHandler.authorisationTypeKeyName).returns(expectedAuthorisationType);
476477

477-
sandbox.stub(Date, 'now').returns(now.getTime());
478+
await authHandler.compareOAuthExpiry();
479+
expect(cliuxPrintStub.called).to.be.false;
480+
expect(refreshTokenStub.called).to.be.false;
481+
expect(unsetConfigDataStub.called).to.be.false;
482+
});
478483

479-
try {
480-
await authHandler.compareOAuthExpiry();
481-
} catch (error) {
482-
expect(error).to.be.undefined;
483-
expect(cliuxPrintStub.called).to.be.false;
484-
expect(refreshTokenStub.called).to.be.false;
485-
expect(unsetConfigDataStub.called).to.be.false;
486-
}
484+
it('should refresh when force is true even if token is not expired', async () => {
485+
const expectedOAuthDateTime = new Date(Date.now() - 30 * 60 * 1000).toISOString();
486+
const expectedAuthorisationType = 'OAUTH';
487+
488+
configHandlerGetStub.withArgs(authHandler.oauthDateTimeKeyName).returns(expectedOAuthDateTime);
489+
configHandlerGetStub.withArgs(authHandler.authorisationTypeKeyName).returns(expectedAuthorisationType);
490+
491+
await authHandler.compareOAuthExpiry(true);
492+
expect(cliuxPrintStub.calledOnceWithExactly('Forcing token refresh...')).to.be.true;
493+
expect(refreshTokenStub.calledOnce).to.be.true;
494+
expect(unsetConfigDataStub.called).to.be.false;
487495
});
488496

489-
it('should resolve if force is true and refreshToken is called', async () => {
490-
const expectedOAuthDateTime = '2023-05-30T12:00:00Z';
491-
const expectedAuthorisationType = 'oauth';
497+
it('should refresh when token is expired', async () => {
498+
const expectedOAuthDateTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
499+
const expectedAuthorisationType = 'OAUTH';
492500

493501
configHandlerGetStub.withArgs(authHandler.oauthDateTimeKeyName).returns(expectedOAuthDateTime);
494502
configHandlerGetStub.withArgs(authHandler.authorisationTypeKeyName).returns(expectedAuthorisationType);
495503

496-
try {
497-
await authHandler.compareOAuthExpiry();
498-
} catch (error) {
499-
expect(error).to.be.undefined;
500-
expect(cliuxPrintStub.calledOnceWithExactly('Forcing token refresh...')).to.be.true;
501-
expect(refreshTokenStub.calledOnce).to.be.true;
502-
expect(unsetConfigDataStub.called).to.be.false;
503-
}
504+
await authHandler.compareOAuthExpiry(false);
505+
expect(cliuxPrintStub.calledOnceWithExactly('Token expired, refreshing the token')).to.be.true;
506+
expect(refreshTokenStub.calledOnce).to.be.true;
507+
});
508+
509+
it('should run a single refresh when compareOAuthExpiry is called concurrently', async () => {
510+
const expectedOAuthDateTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
511+
const expectedAuthorisationType = 'OAUTH';
512+
let resolveRefresh;
513+
const refreshDone = new Promise((r) => {
514+
resolveRefresh = r;
515+
});
516+
517+
configHandlerGetStub.withArgs(authHandler.oauthDateTimeKeyName).returns(expectedOAuthDateTime);
518+
configHandlerGetStub.withArgs(authHandler.authorisationTypeKeyName).returns(expectedAuthorisationType);
519+
520+
refreshTokenStub.callsFake(async () => {
521+
await refreshDone;
522+
});
523+
524+
const p1 = authHandler.compareOAuthExpiry(false);
525+
const p2 = authHandler.compareOAuthExpiry(false);
526+
resolveRefresh();
527+
await Promise.all([p1, p2]);
528+
529+
expect(refreshTokenStub.callCount).to.equal(1);
504530
});
505531
});
506532
});

0 commit comments

Comments
 (0)