From dd625232171ad463fad7e4975d7f325818f18eb2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 22 Mar 2024 11:42:59 +0100 Subject: [PATCH] fix(sync): refresh auth access token automatically (#2314) to avoid 401 request failures and related bugs closes #2225, closes #2044 --- src/app/core/database/sync.service.ts | 3 +- .../keycloak/keycloak-auth.service.spec.ts | 52 +++++++++++++++---- .../auth/keycloak/keycloak-auth.service.ts | 21 ++++++-- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/app/core/database/sync.service.ts b/src/app/core/database/sync.service.ts index 8ef45c0b9f..762fea1c89 100644 --- a/src/app/core/database/sync.service.ts +++ b/src/app/core/database/sync.service.ts @@ -22,7 +22,7 @@ import { from, of } from "rxjs"; export class SyncService { static readonly LAST_SYNC_KEY = "LAST_SYNC"; private readonly POUCHDB_SYNC_BATCH_SIZE = 500; - static readonly SYNC_INTERVAL = 60000; + static readonly SYNC_INTERVAL = 30000; private remoteDatabase = new PouchDatabase(this.loggingService); private remoteDB: PouchDB.Database; @@ -111,6 +111,7 @@ export class SyncService { batch_size: this.POUCHDB_SYNC_BATCH_SIZE, }) .then((res) => { + this.loggingService.debug("sync completed", res); this.syncStateSubject.next(SyncState.COMPLETED); return res as SyncResult; }) diff --git a/src/app/core/session/auth/keycloak/keycloak-auth.service.spec.ts b/src/app/core/session/auth/keycloak/keycloak-auth.service.spec.ts index fb47bc08b4..4336f752d6 100644 --- a/src/app/core/session/auth/keycloak/keycloak-auth.service.spec.ts +++ b/src/app/core/session/auth/keycloak/keycloak-auth.service.spec.ts @@ -1,8 +1,9 @@ -import { TestBed } from "@angular/core/testing"; +import { fakeAsync, TestBed, tick } from "@angular/core/testing"; import { KeycloakAuthService } from "./keycloak-auth.service"; import { HttpClient } from "@angular/common/http"; -import { KeycloakService } from "keycloak-angular"; +import { KeycloakEventType, KeycloakService } from "keycloak-angular"; +import { Subject } from "rxjs"; /** * Check {@link https://jwt.io} to decode the token. @@ -29,14 +30,13 @@ describe("KeycloakAuthService", () => { beforeEach(() => { mockHttpClient = jasmine.createSpyObj(["post"]); - mockKeycloak = jasmine.createSpyObj([ - "updateToken", - "getToken", - "login", - "init", - ]); - mockKeycloak.updateToken.and.resolveTo(); + mockKeycloak = jasmine.createSpyObj( + ["updateToken", "getToken", "login", "init"], + { keycloakEvents$: new Subject() }, + ); mockKeycloak.getToken.and.resolveTo(keycloakToken); + mockKeycloak.updateToken.and.resolveTo(true); + TestBed.configureTestingModule({ providers: [ { provide: HttpClient, useValue: mockHttpClient }, @@ -89,4 +89,38 @@ describe("KeycloakAuthService", () => { service.addAuthHeader(objHeaders); expect(objHeaders["Authorization"]).toBe(`Bearer ${keycloakToken}`); }); + + it("should re-authorize (login) when access token expires", fakeAsync(() => { + service.login(); + tick(); + expect(mockKeycloak.updateToken).toHaveBeenCalled(); + + mockKeycloak.updateToken.calls.reset(); + mockKeycloak.getToken.calls.reset(); + + mockKeycloak.keycloakEvents$.next({ + type: KeycloakEventType.OnTokenExpired, + }); + tick(); + expect(mockKeycloak.updateToken).toHaveBeenCalled(); + expect(mockKeycloak.getToken).toHaveBeenCalled(); + })); + + it("should gracefully handle failed re-authorization", fakeAsync(() => { + service.login(); + tick(); + expect(mockKeycloak.updateToken).toHaveBeenCalled(); + + mockKeycloak.updateToken.calls.reset(); + mockKeycloak.getToken.calls.reset(); + + mockKeycloak.updateToken.and.resolveTo(false); + mockKeycloak.keycloakEvents$.next({ + type: KeycloakEventType.OnTokenExpired, + }); + tick(); + expect(mockKeycloak.updateToken).toHaveBeenCalled(); + // do not getToken if updateToken failed + expect(mockKeycloak.getToken).not.toHaveBeenCalled(); + })); }); diff --git a/src/app/core/session/auth/keycloak/keycloak-auth.service.ts b/src/app/core/session/auth/keycloak/keycloak-auth.service.ts index b4560a8339..a10caebd7a 100644 --- a/src/app/core/session/auth/keycloak/keycloak-auth.service.ts +++ b/src/app/core/session/auth/keycloak/keycloak-auth.service.ts @@ -3,7 +3,7 @@ import { HttpClient } from "@angular/common/http"; import { Observable } from "rxjs"; import { environment } from "../../../../../environments/environment"; import { SessionInfo } from "../session-info"; -import { KeycloakService } from "keycloak-angular"; +import { KeycloakEventType, KeycloakService } from "keycloak-angular"; import { LoggingService } from "../../../logging/logging.service"; import { Entity } from "../../../entity/model/entity"; import { User } from "../../../user/user"; @@ -29,7 +29,7 @@ export class KeycloakAuthService { ) {} /** - * Check for a existing session or forward to the login page. + * Check for an existing session or forward to the login page. */ async login(): Promise { if (!this.keycloakInitialised) { @@ -48,11 +48,26 @@ export class KeycloakAuthService { // Forward to the keycloak login page. await this.keycloak.login({ redirectUri: location.href }); } + + // auto-refresh expiring tokens, as suggested by https://github.com/mauriciovigolo/keycloak-angular?tab=readme-ov-file#keycloak-js-events + this.keycloak.keycloakEvents$.subscribe((event) => { + if (event.type == KeycloakEventType.OnTokenExpired) { + this.login().catch((err) => + this.logger.debug("automatic token refresh failed", err), + ); + } + }); } return this.keycloak .updateToken() - .then(() => this.keycloak.getToken()) + .then((updateSuccessful) => { + if (!updateSuccessful) { + throw new Error("Keycloak updateToken failed"); + // TODO: should we notify the user to manually log in again when failing to refresh token? + } + return this.keycloak.getToken(); + }) .then((token) => this.processToken(token)); }