From 055600a43ba33483f5bebaa77ed95f36d35b7efd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:16:45 +0000 Subject: [PATCH] fix(auth): reset tokenRefreshErrorCount on successful token event The tokenRefreshErrorCount in the oauthErrorEventOccurDueToClockOutOfSync$ stream now resets to 0 when a 'token_received' or 'token_refreshed' event occurs. This prevents intermittent errors that self-correct from accumulating toward the clock-drift detection threshold. --- .../auth/oidc/redirect-auth.service.spec.ts | 64 +++++++++++++++++++ .../lib/auth/oidc/redirect-auth.service.ts | 24 +++++-- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts b/lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts index 14463265df..e440e184eb 100644 --- a/lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts +++ b/lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts @@ -494,6 +494,70 @@ describe('RedirectAuthService', () => { expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); }); + it('should reset token_refresh_error counter when a successful token event occurs', () => { + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync)); + + // First token_refresh_error (skipped by oauthErrorEventOccurDueToClockOutOfSync$) + oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {})); + + // Successful token event resets the counter + oauthEvents$.next(new OAuthSuccessEvent('token_received')); + + // Next token_refresh_error should be treated as the first again (skipped) + // Since secondTokenRefreshErrorEventOccur$ already consumed the first error, + // verify via the oauthErrorEventOccurDueToClockOutOfSync$ observable directly + let emitted = false; + service.oauthErrorEventOccurDueToClockOutOfSync$.subscribe(() => { + emitted = true; + }); + + // After reset, this is treated as "first" by the clock-out-of-sync stream + oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second error after reset' }, {})); + expect(emitted).toBe(false); + }); + + it('should logout on second consecutive token_refresh_error after counter reset and another error', fakeAsync(() => { + const mockDateNowInMilliseconds = 1728597618621; + const tokenExpiresAtInSeconds = 1728597000; // already expired + const tokenIssuedAtInSeconds = 1728596000; + + timeSyncServiceSpy.checkTimeSync.and.returnValue( + of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync) + ); + timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); + oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); + oauthServiceSpy.refreshToken.and.rejectWith('refresh failed'); + oauthServiceSpy.clockSkewInSec = 0; + (oauthServiceSpy as any).decreaseExpirationBySec = 0; + + const expectedErrorMessage = new Error( + 'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z' + ); + + let clockOutOfSyncError: Error | null = null; + service.oauthErrorEventOccurDueToClockOutOfSync$.subscribe((error) => { + clockOutOfSyncError = error; + }); + + // First token_refresh_error (skipped by clock-out-of-sync stream) + oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {})); + tick(); + expect(clockOutOfSyncError).toBeNull(); + + // Successful token event resets the counter + oauthEvents$.next(new OAuthSuccessEvent('token_received')); + + // After reset, first error is skipped again + oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error after reset' }, {})); + tick(); + expect(clockOutOfSyncError).toBeNull(); + + // Second consecutive error after reset triggers clock-out-of-sync detection + oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second consecutive error' }, {})); + tick(); + expect(clockOutOfSyncError).toEqual(expectedErrorMessage); + })); + it('should logout user if discovery_document_load_error is emitted because of clock out of sync', () => { const expectedErrorMessage = new Error( 'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z' diff --git a/lib/core/src/lib/auth/oidc/redirect-auth.service.ts b/lib/core/src/lib/auth/oidc/redirect-auth.service.ts index d8d991f02d..e7fde40532 100644 --- a/lib/core/src/lib/auth/oidc/redirect-auth.service.ts +++ b/lib/core/src/lib/auth/oidc/redirect-auth.service.ts @@ -171,17 +171,27 @@ export class RedirectAuthService extends AuthService { filter((_, index) => index === 1) ); - this.oauthErrorEventOccurDueToClockOutOfSync$ = this.oauthErrorEvent$.pipe( + this.oauthErrorEventOccurDueToClockOutOfSync$ = this.oauthService.events.pipe( // For token_refresh_error, skip the first occurrence so the library's // built-in retry (secondTokenRefreshErrorEventOccur$) has a chance to run // before we conclude the issue is clock drift. All other error types are - // checked immediately. + // checked immediately. The counter resets when a successful token event + // occurs, so intermittent errors that self-correct don't accumulate. scan( - (acc, event) => ({ - event, - shouldProcess: event.type !== 'token_refresh_error' || acc.tokenRefreshErrorCount >= 1, - tokenRefreshErrorCount: event.type === 'token_refresh_error' ? acc.tokenRefreshErrorCount + 1 : acc.tokenRefreshErrorCount - }), + (acc, event) => { + if (event instanceof OAuthErrorEvent) { + return { + event, + shouldProcess: event.type !== 'token_refresh_error' || acc.tokenRefreshErrorCount >= 1, + tokenRefreshErrorCount: event.type === 'token_refresh_error' ? acc.tokenRefreshErrorCount + 1 : acc.tokenRefreshErrorCount + }; + } + return { + event: null, + shouldProcess: false, + tokenRefreshErrorCount: event.type === 'token_received' || event.type === 'token_refreshed' ? 0 : acc.tokenRefreshErrorCount + }; + }, { event: null as OAuthErrorEvent | null, shouldProcess: false, tokenRefreshErrorCount: 0 } ), filter(({ shouldProcess }) => shouldProcess),