From 420a2aae211ddff5a0ddab50e1c38731add36452 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Wed, 10 Jun 2026 11:20:52 +0200 Subject: [PATCH 1/2] Handle token refresh and prevent duplicate extends Wait for token refresh when extending session and guard against rapid duplicate requests. Added an `extending` flag and changed extendSessionAndCloseModal to call authService.refreshAuthenticationToken(this.authService.getToken()) and subscribe with take(1) and finalize to reset the flag. On success the token is replaced before closing the modal; on error a LogOutAction is dispatched and the modal is closed. Updated unit tests to mock getToken/refresh/replaceToken, verify refresh is called with current token, ensure replaceToken happens before modal close, handle refresh failures, prevent duplicate refresh calls, and ensure the modal only closes after the refresh completes. Also added required RxJS/operator imports. --- .../idle-modal/idle-modal.component.spec.ts | 60 ++++++++++++++++++- .../shared/idle-modal/idle-modal.component.ts | 27 ++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts index 7ea0b96d5be..2b7699e7880 100644 --- a/src/app/shared/idle-modal/idle-modal.component.spec.ts +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -2,11 +2,13 @@ import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf, Subject, throwError } from 'rxjs'; import { IdleModalComponent } from './idle-modal.component'; import { AuthService } from '../../core/auth/auth.service'; import { By } from '@angular/platform-browser'; import { Store } from '@ngrx/store'; import { LogOutAction } from '../../core/auth/auth.actions'; +import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; describe('IdleModalComponent', () => { let component: IdleModalComponent; @@ -19,7 +21,10 @@ describe('IdleModalComponent', () => { beforeEach(waitForAsync(() => { modalStub = jasmine.createSpyObj('modalStub', ['close']); - authServiceStub = jasmine.createSpyObj('authService', ['setIdle']); + authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'getToken', 'refreshAuthenticationToken', 'replaceToken']); + const token = new AuthTokenInfo('test-token'); + authServiceStub.getToken.and.returnValue(token); + authServiceStub.refreshAuthenticationToken.and.returnValue(observableOf(token)); storeStub = jasmine.createSpyObj('store', ['dispatch']); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], @@ -58,6 +63,59 @@ describe('IdleModalComponent', () => { it('response \'closed\' should emit true', () => { expect(component.response.emit).toHaveBeenCalledWith(true); }); + + it('should refresh authentication token with current token', () => { + expect(authServiceStub.refreshAuthenticationToken).toHaveBeenCalledWith(authServiceStub.getToken.calls.mostRecent().returnValue); + }); + + it('should replace token before closing modal', () => { + const replaceTokenOrder = authServiceStub.replaceToken.calls.first().invocationOrder; + const closeOrder = modalStub.close.calls.first().invocationOrder; + expect(replaceTokenOrder).toBeLessThan(closeOrder); + }); + }); + + describe('extendSessionAndCloseModal hardening', () => { + it('should dispatch LogOutAction and close modal when refresh fails', () => { + authServiceStub.refreshAuthenticationToken.and.returnValue(throwError(() => new Error('refresh failed'))); + spyOn(component, 'closeModal').and.callThrough(); + + component.extendSessionAndCloseModal(); + + expect(storeStub.dispatch).toHaveBeenCalledWith(new LogOutAction()); + expect(component.closeModal).toHaveBeenCalled(); + expect(modalStub.close).toHaveBeenCalled(); + }); + + it('should prevent duplicate refresh requests on rapid double click', () => { + const refresh$ = new Subject(); + authServiceStub.refreshAuthenticationToken.and.returnValue(refresh$.asObservable()); + + component.extendSessionAndCloseModal(); + component.extendSessionAndCloseModal(); + + expect(authServiceStub.refreshAuthenticationToken).toHaveBeenCalledTimes(1); + + refresh$.next(new AuthTokenInfo('updated-token')); + refresh$.complete(); + }); + + it('should not close modal before token refresh completes', () => { + const refresh$ = new Subject(); + authServiceStub.refreshAuthenticationToken.and.returnValue(refresh$.asObservable()); + spyOn(component, 'closeModal').and.callThrough(); + + component.extendSessionAndCloseModal(); + + expect(component.closeModal).not.toHaveBeenCalled(); + expect(modalStub.close).not.toHaveBeenCalled(); + + refresh$.next(new AuthTokenInfo('updated-token')); + refresh$.complete(); + + expect(component.closeModal).toHaveBeenCalledTimes(1); + expect(modalStub.close).toHaveBeenCalledTimes(1); + }); }); describe('logOutPressed', () => { diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts index 4873137ff1e..c7cfd451396 100644 --- a/src/app/shared/idle-modal/idle-modal.component.ts +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -6,6 +6,7 @@ import { hasValue } from '../empty.util'; import { Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; import { LogOutAction } from '../../core/auth/auth.actions'; +import { finalize, take } from 'rxjs/operators'; @Component({ selector: 'ds-idle-modal', @@ -24,6 +25,11 @@ export class IdleModalComponent implements OnInit { */ private graceTimer; + /** + * Guards against multiple rapid extension attempts. + */ + private extending = false; + /** * An event fired when the modal is closed */ @@ -71,11 +77,30 @@ export class IdleModalComponent implements OnInit { * Close the modal and extend session */ extendSessionAndCloseModal() { + if (this.extending) { + return; + } + this.extending = true; + if (hasValue(this.graceTimer)) { clearTimeout(this.graceTimer); } + this.authService.setIdle(false); - this.closeModal(); + + this.authService.refreshAuthenticationToken(this.authService.getToken()).pipe( + take(1), + finalize(() => this.extending = false) + ).subscribe({ + next: (token) => { + this.authService.replaceToken(token); + this.closeModal(); + }, + error: () => { + this.store.dispatch(new LogOutAction()); + this.closeModal(); + } + }); } /** From 213e33a0dcaaad372ac462fce6a992d7db936558 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Wed, 10 Jun 2026 13:52:57 +0200 Subject: [PATCH 2/2] fix(idle-modal): address valid Copilot review comments - add subscription cleanup and use RefreshTokenSuccessAction --- .../shared/idle-modal/idle-modal.component.spec.ts | 14 ++++++++------ src/app/shared/idle-modal/idle-modal.component.ts | 14 +++++++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts index 2b7699e7880..d1be0976fa3 100644 --- a/src/app/shared/idle-modal/idle-modal.component.spec.ts +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -7,7 +7,7 @@ import { IdleModalComponent } from './idle-modal.component'; import { AuthService } from '../../core/auth/auth.service'; import { By } from '@angular/platform-browser'; import { Store } from '@ngrx/store'; -import { LogOutAction } from '../../core/auth/auth.actions'; +import { LogOutAction, RefreshTokenSuccessAction } from '../../core/auth/auth.actions'; import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; describe('IdleModalComponent', () => { @@ -21,7 +21,7 @@ describe('IdleModalComponent', () => { beforeEach(waitForAsync(() => { modalStub = jasmine.createSpyObj('modalStub', ['close']); - authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'getToken', 'refreshAuthenticationToken', 'replaceToken']); + authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'getToken', 'refreshAuthenticationToken']); const token = new AuthTokenInfo('test-token'); authServiceStub.getToken.and.returnValue(token); authServiceStub.refreshAuthenticationToken.and.returnValue(observableOf(token)); @@ -65,13 +65,15 @@ describe('IdleModalComponent', () => { }); it('should refresh authentication token with current token', () => { - expect(authServiceStub.refreshAuthenticationToken).toHaveBeenCalledWith(authServiceStub.getToken.calls.mostRecent().returnValue); + const currentToken = authServiceStub.getToken(); + expect(authServiceStub.refreshAuthenticationToken).toHaveBeenCalledWith(currentToken); }); - it('should replace token before closing modal', () => { - const replaceTokenOrder = authServiceStub.replaceToken.calls.first().invocationOrder; + it('should dispatch refreshed token before closing modal', () => { + expect(storeStub.dispatch).toHaveBeenCalledWith(new RefreshTokenSuccessAction(authServiceStub.getToken())); + const dispatchOrder = storeStub.dispatch.calls.first().invocationOrder; const closeOrder = modalStub.close.calls.first().invocationOrder; - expect(replaceTokenOrder).toBeLessThan(closeOrder); + expect(dispatchOrder).toBeLessThan(closeOrder); }); }); diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts index c7cfd451396..fbd6a6e1b02 100644 --- a/src/app/shared/idle-modal/idle-modal.component.ts +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -5,7 +5,8 @@ import { AuthService } from '../../core/auth/auth.service'; import { hasValue } from '../empty.util'; import { Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; -import { LogOutAction } from '../../core/auth/auth.actions'; +import { LogOutAction, RefreshTokenSuccessAction } from '../../core/auth/auth.actions'; +import { Subscription } from 'rxjs'; import { finalize, take } from 'rxjs/operators'; @Component({ @@ -30,6 +31,11 @@ export class IdleModalComponent implements OnInit { */ private extending = false; + /** + * Tracks the in-flight refresh subscription for cancellation on logout. + */ + private refreshSubscription: Subscription; + /** * An event fired when the modal is closed */ @@ -88,12 +94,12 @@ export class IdleModalComponent implements OnInit { this.authService.setIdle(false); - this.authService.refreshAuthenticationToken(this.authService.getToken()).pipe( + this.refreshSubscription = this.authService.refreshAuthenticationToken(this.authService.getToken()).pipe( take(1), finalize(() => this.extending = false) ).subscribe({ next: (token) => { - this.authService.replaceToken(token); + this.store.dispatch(new RefreshTokenSuccessAction(token)); this.closeModal(); }, error: () => { @@ -107,6 +113,8 @@ export class IdleModalComponent implements OnInit { * Close the modal and set the response to true so RootComponent knows the modal was closed */ closeModal() { + this.refreshSubscription?.unsubscribe(); + this.extending = false; this.activeModal.close(); this.response.emit(true); }