Skip to content

Commit 5824ef3

Browse files
Merge pull request #2481 from contentstack/fix/dx-3878-oauth-session-expire
Refactor OAuth token refresh logic in AuthHandler to prevent concurre…
2 parents 5ee49d4 + aa39c28 commit 5824ef3

File tree

2 files changed

+81
-52
lines changed

2 files changed

+81
-52
lines changed

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)