Skip to content

Commit

Permalink
fix(sync): refresh auth access token automatically (#2314)
Browse files Browse the repository at this point in the history
to avoid 401 request failures and related bugs

closes #2225, closes #2044
  • Loading branch information
sleidig authored Mar 22, 2024
1 parent b2f7234 commit dd62523
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 13 deletions.
3 changes: 2 additions & 1 deletion src/app/core/database/sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
})
Expand Down
52 changes: 43 additions & 9 deletions src/app/core/session/auth/keycloak/keycloak-auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 },
Expand Down Expand Up @@ -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();
}));
});
21 changes: 18 additions & 3 deletions src/app/core/session/auth/keycloak/keycloak-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<SessionInfo> {
if (!this.keycloakInitialised) {
Expand All @@ -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));
}

Expand Down

0 comments on commit dd62523

Please sign in to comment.