diff --git a/frontend/app/src/app/modules/auth/auth.provider.spec.ts b/frontend/app/src/app/modules/auth/auth.provider.spec.ts new file mode 100644 index 00000000..6fc96ee5 --- /dev/null +++ b/frontend/app/src/app/modules/auth/auth.provider.spec.ts @@ -0,0 +1,187 @@ +import { + AuthorizationServiceConfiguration, + TokenResponse, +} from '@openid/appauth'; +import {firstValueFrom} from 'rxjs'; + +import { + AuthActions, + AuthProvider, +} from './auth.provider'; + +describe('AuthProvider', () => { + let provider: AuthProvider; + + beforeEach(() => { + const platform = { + is: () => false, + } as any; + + const configProvider = {} as any; + + provider = new AuthProvider( + platform, + configProvider, + ); + + (provider as any).authConfigValue = { + server_host: 'https://idp.example.org', + client_id: 'test-client', + redirect_url: 'https://app.example.org/callback', + scopes: 'openid profile', + pkce: true, + }; + + (provider as any).localConfiguration = + new AuthorizationServiceConfiguration({ + authorization_endpoint: + 'https://idp.example.org/authorize', + + token_endpoint: + 'https://idp.example.org/token', + + revocation_endpoint: + 'https://idp.example.org/revoke', + }); + }); + + it('refreshes the token when the access token is expired', async () => { + const expiredToken = new TokenResponse({ + access_token: 'expired-access-token', + refresh_token: 'valid-refresh-token', + token_type: 'bearer', + }); + + spyOn( + expiredToken, + 'isValid', + ).and.returnValue(false); + + (provider as any).tokenSubject.next( + expiredToken, + ); + + const refreshedToken = new TokenResponse({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + expires_in: '3600', + }); + + spyOn( + refreshedToken, + 'isValid', + ).and.returnValue(true); + + const performTokenRequest = + spyOn( + (provider as any).tokenHandler, + 'performTokenRequest', + ).and.resolveTo( + refreshedToken, + ); + + spyOn( + (provider as any).storage, + 'setItem', + ).and.resolveTo(); + + const token = + await provider.getValidToken(); + + expect( + performTokenRequest, + ).toHaveBeenCalled(); + + expect( + token.accessToken, + ).toBe( + 'new-access-token', + ); + + expect( + token.refreshToken, + ).toBe( + 'new-refresh-token', + ); + }); + + it('fails when the refresh token is expired', async () => { + const expiredToken = new TokenResponse({ + access_token: 'expired-access-token', + refresh_token: 'expired-refresh-token', + token_type: 'bearer', + }); + + spyOn( + expiredToken, + 'isValid', + ).and.returnValue(false); + + (provider as any).tokenSubject.next( + expiredToken, + ); + + spyOn( + (provider as any).tokenHandler, + 'performTokenRequest', + ).and.returnValue( + Promise.reject( + new Error('invalid_grant'), + ), + ); + + await expectAsync( + provider.getValidToken(), + ).toBeRejectedWithError( + 'invalid_grant', + ); + }); + + it('changes isLoggedIn to false when the refresh token fails', async () => { + const expiredToken = new TokenResponse({ + access_token: 'expired-access-token', + refresh_token: 'expired-refresh-token', + token_type: 'bearer', + }); + + spyOn( + expiredToken, + 'isValid', + ).and.returnValue(false); + + (provider as any).notify({ + action: + AuthActions.LoadTokenFromStorageSuccess, + tokenResponse: + expiredToken, + }); + + expect( + await firstValueFrom( + provider.isLoggedIn$, + ), + ).toBeTrue(); + + spyOn( + (provider as any).tokenHandler, + 'performTokenRequest', + ).and.returnValue( + Promise.reject( + new Error('invalid_grant'), + ), + ); + + await expectAsync( + provider.getValidToken(), + ).toBeRejectedWithError( + 'invalid_grant', + ); + + expect( + await firstValueFrom( + provider.isLoggedIn$, + ), + ).toBeFalse(); + }); +}); diff --git a/frontend/app/src/app/modules/auth/auth.provider.ts b/frontend/app/src/app/modules/auth/auth.provider.ts index f4a56a4b..899da55e 100644 --- a/frontend/app/src/app/modules/auth/auth.provider.ts +++ b/frontend/app/src/app/modules/auth/auth.provider.ts @@ -554,7 +554,33 @@ export class AuthProvider { * requestTokenRefresh() stores the newly returned token * and updates tokenSubject via RefreshSuccess. */ - await this.requestTokenRefresh(); + try { + await this.requestTokenRefresh(); + } catch (error) { + /* + * An invalid_grant response means that the refresh token + * is no longer usable, for example because it expired or + * was revoked. + * + * Network errors must not log the user out. + */ + if ( + error instanceof Error && + error.message.includes( + 'invalid_grant', + ) + ) { + this.authenticatedSubject.next( + false, + ); + + this.loggedInSubject.next( + false, + ); + } + + throw error; + } const refreshedToken = this.tokenSubject.value; @@ -875,7 +901,6 @@ export class AuthProvider { .completeAuthorizationRequestIfPossible(); } - private async internalEndSessionCallback(): Promise { await this.closeBrowser(); diff --git a/frontend/app/src/app/modules/profile/id-cards.spec.ts b/frontend/app/src/app/modules/profile/id-cards.spec.ts index a42d2b11..de6f61ff 100644 --- a/frontend/app/src/app/modules/profile/id-cards.spec.ts +++ b/frontend/app/src/app/modules/profile/id-cards.spec.ts @@ -1,70 +1,167 @@ -import {ConfigProvider} from '../config/config.provider'; -import {IdCardsProvider} from './id-cards.provider'; import {HttpClient} from '@angular/common/http'; -import {AuthHelperService} from '../auth/auth-helper.service'; import {BehaviorSubject, firstValueFrom, of} from 'rxjs'; + +import {AuthHelperService} from '../auth/auth-helper.service'; +import {AuthProvider} from '../auth/auth.provider'; +import {ConfigProvider} from '../config/config.provider'; import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider'; +import {IdCardsProvider} from './id-cards.provider'; class FakeAuth { isLoggedIn$ = new BehaviorSubject(false); - // eslint-disable-next-line @typescript-eslint/no-empty-function - getValidToken() {} + getValidToken = jasmine.createSpy('getValidToken'); } describe('IdCards', () => { + let authHelper: AuthHelperService; let configProvider: ConfigProvider; let httpClient: HttpClient; - let authHelper: AuthHelperService; + let authProvider: AuthProvider; let encryptedStorageProvider: EncryptedStorageProvider; let fakeAuth: FakeAuth; beforeEach(() => { - configProvider = jasmine.createSpyObj('ConfigProvider', ['config']); + authHelper = jasmine.createSpyObj( + 'AuthHelperService', + ['getUserFromUserInfo'], + ); + + configProvider = jasmine.createSpyObj( + 'ConfigProvider', + ['config'], + ); + configProvider.config = { - app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'fakeAuth'}}}}, + app: { + features: { + extern: { + idCards: { + url: 'http://id-cards.local', + authProvider: 'fakeAuth', + }, + }, + }, + }, } as never; - httpClient = jasmine.createSpyObj('HttpClient', ['get']); + + httpClient = jasmine.createSpyObj( + 'HttpClient', + ['get'], + ); + fakeAuth = new FakeAuth(); - authHelper = jasmine.createSpyObj('AuthHelperService', ['getProvider']); - authHelper.getProvider = jasmine.createSpy().and.returnValue(fakeAuth); - encryptedStorageProvider = jasmine.createSpyObj('EncryptedStorageProvider', ['get', 'set', 'delete']); - encryptedStorageProvider.get = jasmine.createSpy().and.resolveTo(); - encryptedStorageProvider.set = jasmine.createSpy().and.resolveTo(); - encryptedStorageProvider.delete = jasmine.createSpy().and.resolveTo(); + + authProvider = + fakeAuth as unknown as AuthProvider; + + encryptedStorageProvider = + jasmine.createSpyObj( + 'EncryptedStorageProvider', + ['get', 'set', 'delete'], + ); + + encryptedStorageProvider.get = + jasmine.createSpy().and.resolveTo(); + + encryptedStorageProvider.set = + jasmine.createSpy().and.resolveTo(); + + encryptedStorageProvider.delete = + jasmine.createSpy().and.resolveTo(); }); it('should emit undefined if not logged in', async () => { - const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider); - expect(await firstValueFrom(provider.getIdCards())).toEqual([]); - expect(authHelper.getProvider).toHaveBeenCalledTimes(1); + const provider = new IdCardsProvider( + authHelper, + authProvider, + configProvider, + httpClient, + encryptedStorageProvider, + ); + + expect( + await firstValueFrom( + provider.getIdCards(), + ), + ).toEqual([]); }); it('should emit network result when logged in', async () => { fakeAuth.isLoggedIn$.next(true); - httpClient.get = jasmine.createSpy().and.returnValue(of(['abc'])); - fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'}); - const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider); - expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]); - expect(authHelper.getProvider).toHaveBeenCalledTimes(1); - // eslint-disable-next-line unicorn/no-null - expect(httpClient.get).toHaveBeenCalledOnceWith('http://id-cards.local', { - headers: { - Authorization: 'Bearer fake-token', - }, - responseType: 'json', + + httpClient.get = + jasmine.createSpy().and.returnValue( + of(['abc']), + ); + + fakeAuth.getValidToken.and.resolveTo({ + accessToken: 'fake-token', }); + + const provider = new IdCardsProvider( + authHelper, + authProvider, + configProvider, + httpClient, + encryptedStorageProvider, + ); + + expect( + await firstValueFrom( + provider.getIdCards(), + ), + ).toEqual([ + 'abc' as never, + ]); + + // eslint-disable-next-line unicorn/no-null + expect(httpClient.get) + .toHaveBeenCalledOnceWith( + 'http://id-cards.local', + { + headers: { + Authorization: + 'Bearer fake-token', + }, + responseType: 'json', + }, + ); }); it('should react to logins', async () => { - const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider); - const observable = provider.getIdCards(); - expect(await firstValueFrom(observable)).toEqual([]); - httpClient.get = jasmine.createSpy().and.returnValue(of(['abc'])); - fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'}); + const provider = new IdCardsProvider( + authHelper, + authProvider, + configProvider, + httpClient, + encryptedStorageProvider, + ); + + const observable = + provider.getIdCards(); + + expect( + await firstValueFrom(observable), + ).toEqual([]); + + httpClient.get = + jasmine.createSpy().and.returnValue( + of(['abc']), + ); + + fakeAuth.getValidToken.and.resolveTo({ + accessToken: 'fake-token', + }); + fakeAuth.isLoggedIn$.next(true); + // this is counter-intuitive, but because we unsubscribed above the first value // will now contain the network result. - expect(await firstValueFrom(observable)).toEqual(['abc' as never]); + expect( + await firstValueFrom(observable), + ).toEqual([ + 'abc' as never, + ]); }); });