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 7b439a536a..6134b27693 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 @@ -16,7 +16,7 @@ */ import { TestBed } from '@angular/core/testing'; -import { OAuthService, OAuthEvent, OAuthStorage, AUTH_CONFIG } from 'angular-oauth2-oidc'; +import { OAuthService, OAuthEvent, OAuthStorage, AUTH_CONFIG, TokenResponse } from 'angular-oauth2-oidc'; import { Subject } from 'rxjs'; import { RedirectAuthService } from './redirect-auth.service'; import { AUTH_MODULE_CONFIG } from './auth-config'; @@ -31,7 +31,13 @@ describe('RedirectAuthService', () => { const oauthEvents$ = new Subject(); const mockOauthService: Partial = { clearHashAfterLogin: false, - events: oauthEvents$ + events: oauthEvents$, + configure: () => {}, + hasValidAccessToken: jasmine.createSpy().and.returnValue(true), + setupAutomaticSilentRefresh: () => { + mockOauthService.silentRefresh(); + mockOauthService.refreshToken(); + } }; beforeEach(() => { @@ -45,8 +51,10 @@ describe('RedirectAuthService', () => { ] }); - service = TestBed.inject(RedirectAuthService); TestBed.inject(OAuthService); + service = TestBed.inject(RedirectAuthService); + spyOn(service, 'ensureDiscoveryDocument').and.resolveTo(true); + mockOauthService.getAccessToken = () => 'access-token'; }); it('should emit event when token_received event is received', () => { @@ -66,4 +74,23 @@ describe('RedirectAuthService', () => { expect(onTokenReceivedSpy).not.toHaveBeenCalled(); }); + + it('should call refresh token and silent refresh when automatic silent refresh is setup', async () => { + let refreshTokenCalled = false; + let silentRefreshCalled = false; + + mockOauthService.refreshToken = async () => { + refreshTokenCalled = true; + return Promise.resolve({} as TokenResponse); + }; + mockOauthService.silentRefresh = async () => { + silentRefreshCalled = true; + return Promise.resolve({} as OAuthEvent); + }; + + await service.init(); + + expect(refreshTokenCalled).toBe(true); + expect(silentRefreshCalled).toBe(true); + }); }); 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 0efd0d127a..edfc2d141a 100644 --- a/lib/core/src/lib/auth/oidc/redirect-auth.service.ts +++ b/lib/core/src/lib/auth/oidc/redirect-auth.service.ts @@ -16,7 +16,7 @@ */ import { Inject, Injectable, inject } from '@angular/core'; -import { AuthConfig, AUTH_CONFIG, OAuthErrorEvent, OAuthEvent, OAuthService, OAuthStorage, TokenResponse, LoginOptions } from 'angular-oauth2-oidc'; +import { AuthConfig, AUTH_CONFIG, OAuthErrorEvent, OAuthEvent, OAuthService, OAuthStorage, TokenResponse, LoginOptions, OAuthSuccessEvent } from 'angular-oauth2-oidc'; import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'; import { from, Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, shareReplay } from 'rxjs/operators'; @@ -169,13 +169,57 @@ export class RedirectAuthService extends AuthService { }); } - return this.ensureDiscoveryDocument().then(() => - void this.oauthService.setupAutomaticSilentRefresh() - ).catch(() => { + return this.ensureDiscoveryDocument().then(() => { + this.oauthService.setupAutomaticSilentRefresh(); + return void this.allowRefreshTokenAndSilentRefreshOnMultipleTabs(); + }).catch(() => { // catch error to prevent the app from crashing when trying to access unprotected routes }); } + /** + * Fix a known issue (https://github.com/manfredsteyer/angular-oauth2-oidc/issues/850) + * where multiple tabs can cause the token refresh and the silent refresh to fail. + * This patch is based on the solutions provided in the following comments: + * https://github.com/manfredsteyer/angular-oauth2-oidc/issues/850#issuecomment-889921776 fix silent refresh for the implicit flow + * https://github.com/manfredsteyer/angular-oauth2-oidc/issues/850#issuecomment-1557286966 fix refresh token for the code flow + */ + private allowRefreshTokenAndSilentRefreshOnMultipleTabs() { + let lastUpdatedAccessToken: string | undefined; + + if (this.oauthService.hasValidAccessToken()) { + lastUpdatedAccessToken = this.oauthService.getAccessToken(); + } + + const originalRefreshToken = this.oauthService.refreshToken.bind(this.oauthService); + this.oauthService.refreshToken = (): Promise => + navigator.locks.request(`refresh_tokens_${location.origin}`, () => { + if (!!lastUpdatedAccessToken && lastUpdatedAccessToken !== this.oauthService.getAccessToken()) { + (this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_received')); + (this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_refreshed')); + lastUpdatedAccessToken = this.oauthService.getAccessToken(); + return; + } + + return originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token)); + }); + + const originalSilentRefresh = this.oauthService.silentRefresh.bind(this.oauthService); + this.oauthService.silentRefresh = async (params: any = {}, noPrompt = true): Promise => + navigator.locks.request(`silent_refresh_${location.origin}`, async (): Promise => { + if (lastUpdatedAccessToken !== this.oauthService.getAccessToken()) { + (this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_received')); + (this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_refreshed')); + const event = new OAuthSuccessEvent('silently_refreshed'); + (this.oauthService as any).eventsSubject.next(event); + lastUpdatedAccessToken = this.oauthService.getAccessToken(); + return event; + } else { + return originalSilentRefresh(params, noPrompt); + } + }); + } + updateIDPConfiguration(config: AuthConfig) { this.oauthService.configure(config); }