Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keycloak automatic refresh access tokens #2314

Merged
merged 9 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7,999 changes: 3,470 additions & 4,529 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@
"hammerjs": "^2.0.8",
"json-query": "^2.2.2",
"keycloak-angular": "^15.1.0",
"keycloak-js": "^22.0.5",
"keycloak-js": "^24.0.1",
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"moment": "^2.29.4",
"moment": "2.29.4",
"ngx-markdown": "^17.1.1",
"ngx-papaparse": "^8.0.0",
"pouchdb-adapter-memory": "^8.0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export class DateImportConfigComponent {
checkDateValues() {
this.format.setErrors(undefined);
this.values.forEach((val) => {
// TODO: check and improve the date parsing. Tests fail with moment.js > 2.29
const date = moment(val.value, this.format.value?.toUpperCase(), true);
if (date.isValid()) {
val.parsed = date.toDate();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { FormFieldConfig } from "./FormConfig";
import { User } from "../../user/user";
import { TEST_USER } from "../../user/demo-user-generator.service";
import { CurrentUserSubject } from "../../session/current-user-subject";
import moment from "moment";

describe("EntityFormService", () => {
let service: EntityFormService;
Expand Down Expand Up @@ -248,7 +249,9 @@ describe("EntityFormService", () => {

schema.defaultValue = PLACEHOLDERS.NOW;
form = service.createFormGroup([{ id: "test" }], new Entity());
expect(form.get("test").value).toBeDate(new Date());
expect(
moment(form.get("test").value).isSame(moment(), "minutes"),
).toBeTrue();

schema.defaultValue = PLACEHOLDERS.CURRENT_USER;
form = service.createFormGroup([{ id: "test" }], new Entity());
Expand Down
54 changes: 20 additions & 34 deletions src/app/core/database/sync.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,47 +33,34 @@ describe("SyncService", () => {
});

it("should restart the sync if it fails at one point", fakeAsync(() => {
let errorCallback, pauseCallback;
const syncHandle = {
on: (action, callback) => {
if (action === "error") {
errorCallback = callback;
}
if (action === "paused") {
pauseCallback = callback;
}
return syncHandle;
},
cancel: () => undefined,
};
const syncSpy = jasmine
.createSpy()
.and.returnValues(Promise.resolve("first"), syncHandle, syncHandle);
const mockLocalDb = jasmine.createSpyObj(["sync"]);
spyOn(
TestBed.inject(Database) as PouchDatabase,
"getPouchDB",
).and.returnValue({ sync: syncSpy } as any);
).and.returnValue(mockLocalDb);

loginState.next(LoginState.LOGGED_IN);

service.startSync();

mockLocalDb.sync.and.resolveTo({});
tick(1000);
expect(mockLocalDb.sync).toHaveBeenCalled();

// error + logged in -> sync should restart
loginState.next(LoginState.LOGGED_IN);
syncSpy.calls.reset();
errorCallback();
expect(syncSpy).toHaveBeenCalled();
mockLocalDb.sync.calls.reset();
mockLocalDb.sync.and.rejectWith("sync request server error");
tick(SyncService.SYNC_INTERVAL);
expect(mockLocalDb.sync).toHaveBeenCalled();
// expect no errors thrown in service

// pause -> no restart required
syncSpy.calls.reset();
pauseCallback();
expect(syncSpy).not.toHaveBeenCalled();
// continue sync intervals
mockLocalDb.sync.calls.reset();
mockLocalDb.sync.and.resolveTo({});
tick(SyncService.SYNC_INTERVAL);
expect(mockLocalDb.sync).toHaveBeenCalled();

// logout + error -> no restart
syncSpy.calls.reset();
loginState.next(LoginState.LOGGED_OUT);
tick();
errorCallback();
expect(syncSpy).not.toHaveBeenCalled();
service.liveSyncEnabled = false;
tick(SyncService.SYNC_INTERVAL);
}));

it("should try auto-login if fetch fails and fetch again", async () => {
Expand Down Expand Up @@ -110,7 +97,6 @@ describe("SyncService", () => {
expect(mockAuthService.login).toHaveBeenCalled();
expect(mockAuthService.addAuthHeader).toHaveBeenCalledTimes(2);

// prevent live sync call
service["cancelLiveSync"]();
service.liveSyncEnabled = false;
});
});
105 changes: 25 additions & 80 deletions src/app/core/database/sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { AppSettings } from "../app-settings";
import { HttpStatusCode } from "@angular/common/http";
import PouchDB from "pouchdb-browser";
import { SyncState } from "../session/session-states/sync-state.enum";
import { LoginStateSubject, SyncStateSubject } from "../session/session-type";
import { LoginState } from "../session/session-states/login-state.enum";
import { filter } from "rxjs/operators";
import { SyncStateSubject } from "../session/session-type";
import { filter, mergeMap, repeat, retry, takeWhile } from "rxjs/operators";
import { KeycloakAuthService } from "../session/auth/keycloak/keycloak-auth.service";
import { Config } from "../config/config";
import { Entity } from "../entity/model/entity";
import { from, of } from "rxjs";

/**
* This service initializes the remote DB and manages the sync between the local and remote DB.
Expand All @@ -22,8 +22,8 @@ import { Entity } from "../entity/model/entity";
export class SyncService {
static readonly LAST_SYNC_KEY = "LAST_SYNC";
private readonly POUCHDB_SYNC_BATCH_SIZE = 500;
private _liveSyncHandle: any;
private _liveSyncScheduledHandle: any;
static readonly SYNC_INTERVAL = 30000;

private remoteDatabase = new PouchDatabase(this.loggingService);
private remoteDB: PouchDB.Database;
private localDB: PouchDB.Database;
Expand All @@ -33,7 +33,6 @@ export class SyncService {
private loggingService: LoggingService,
private authService: KeycloakAuthService,
private syncStateSubject: SyncStateSubject,
private loginStateSubject: LoginStateSubject,
) {
this.logSyncContext();

Expand Down Expand Up @@ -64,10 +63,7 @@ export class SyncService {
*/
startSync() {
this.initDatabases();
this.sync()
.catch((err) => this.loggingService.warn(`Initial sync failed: ${err}`))
// Call live sync even when initial sync fails
.finally(() => this.liveSyncDeferred());
this.liveSync();
}

/**
Expand Down Expand Up @@ -107,92 +103,41 @@ export class SyncService {
return PouchDB.fetch(url, opts);
}

private sync(): Promise<any> {
private sync(): Promise<SyncResult> {
this.syncStateSubject.next(SyncState.STARTED);

return this.localDB
.sync(this.remoteDB, {
batch_size: this.POUCHDB_SYNC_BATCH_SIZE,
})
.then(() => {
.then((res) => {
this.loggingService.debug("sync completed", res);
this.syncStateSubject.next(SyncState.COMPLETED);
return res as SyncResult;
})
.catch((err) => {
this.loggingService.debug("sync error", err);
this.syncStateSubject.next(SyncState.FAILED);
throw err;
});
}

/**
* Schedules liveSync to be started.
* This method should be used to start the liveSync after the initial non-live sync,
* so the browser makes a round trip to the UI and hides the potentially visible first-sync dialog.
* @param timeout ms to wait before starting the liveSync
*/
private liveSyncDeferred(timeout = 1000) {
this._liveSyncScheduledHandle = setTimeout(() => this.liveSync(), timeout);
}

/**
* Start live sync in background.
* Continuous syncing in background.
*/
liveSyncEnabled: boolean;
private liveSync() {
this.cancelLiveSync(); // cancel any liveSync that may have been alive before
this.syncStateSubject.next(SyncState.STARTED);
this._liveSyncHandle = this.localDB.sync(this.remoteDB, {
live: true,
retry: true,
});
this._liveSyncHandle
.on("paused", () => {
// replication was paused: either because sync is finished or because of a failed sync (mostly due to lost connection). info is empty.
if (this.isLoggedIn()) {
this.syncStateSubject.next(SyncState.COMPLETED);
// We might end up here after a failed sync that is not due to offline errors.
// It shouldn't happen too often, as we have an initial non-live sync to catch those situations, but we can't find that out here
}
})
.on("active", () => {
// replication was resumed: either because new things to sync or because connection is available again. info contains the direction
this.syncStateSubject.next(SyncState.STARTED);
})
.on("error", this.handleFailedSync())
.on("complete", (info) => {
this.loggingService.info(
`Live sync completed: ${JSON.stringify(info)}`,
);
this.syncStateSubject.next(SyncState.COMPLETED);
});
}

private handleFailedSync() {
return (info) => {
if (this.isLoggedIn()) {
this.syncStateSubject.next(SyncState.FAILED);
const lastAuth = localStorage.getItem(
KeycloakAuthService.LAST_AUTH_KEY,
);
this.loggingService.warn(
`Live sync failed (last auth ${lastAuth}): ${JSON.stringify(info)}`,
);
this.liveSync();
}
};
}

private isLoggedIn(): boolean {
return this.loginStateSubject.value === LoginState.LOGGED_IN;
}
this.liveSyncEnabled = true;

/**
* Cancels a currently running liveSync or a liveSync scheduled to start in the future.
*/
private cancelLiveSync() {
if (this._liveSyncScheduledHandle) {
clearTimeout(this._liveSyncScheduledHandle);
}
if (this._liveSyncHandle) {
this._liveSyncHandle.cancel();
}
this.syncStateSubject.next(SyncState.UNSYNCED);
of(true)
.pipe(
mergeMap(() => from(this.sync())),
retry({ delay: SyncService.SYNC_INTERVAL }),
repeat({ delay: SyncService.SYNC_INTERVAL }),
takeWhile(() => this.liveSyncEnabled),
)
.subscribe();
}
}

type SyncResult = PouchDB.Replication.SyncResultComplete<any>;
5 changes: 3 additions & 2 deletions src/app/core/logging/logging.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ describe("LoggingService", () => {
expect(loggingService).toBeTruthy();
});

it("should log a debug message", function () {
loggingService.debug(testMessage);
it("should log a debug message with additional context", function () {
loggingService.debug(testMessage, "extra context");

expect(loggingService["logToConsole"]).toHaveBeenCalledWith(
testMessage,
LogLevel.DEBUG,
"extra context",
);
expect(loggingService["logToRemoteMonitoring"]).not.toHaveBeenCalled();
});
Expand Down
26 changes: 16 additions & 10 deletions src/app/core/logging/logging.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ export class LoggingService {
/**
* Log the message with "debug" level - for very detailed, non-essential information.
* @param message
* @param context Additional context for debugging
*/
public debug(message: any) {
this.log(message, LogLevel.DEBUG);
public debug(message: any, ...context: any[]) {
this.log(message, LogLevel.DEBUG, ...context);
}

/**
Expand Down Expand Up @@ -98,31 +99,36 @@ export class LoggingService {
* Generic logging of a message.
* @param message Message to be logged
* @param logLevel Optional log level - default is "info"
* @param context Additional context for debugging
*/
public log(message: any, logLevel: LogLevel = LogLevel.INFO) {
this.logToConsole(message, logLevel);
public log(
message: any,
logLevel: LogLevel = LogLevel.INFO,
...context: any[]
) {
this.logToConsole(message, logLevel, ...context);

if (logLevel !== LogLevel.DEBUG && logLevel !== LogLevel.INFO) {
this.logToRemoteMonitoring(message, logLevel);
}
}

private logToConsole(message: any, logLevel: LogLevel) {
private logToConsole(message: any, logLevel: LogLevel, ...context: any[]) {
switch (+logLevel) {
case LogLevel.DEBUG:
console.debug(message);
console.debug(message, ...context);
break;
case LogLevel.INFO:
console.info(message);
console.info(message, ...context);
break;
case LogLevel.WARN:
console.warn(message);
console.warn(message, ...context);
break;
case LogLevel.ERROR:
console.error(message);
console.error(message, ...context);
break;
default:
console.log(message);
console.log(message, ...context);
break;
}
}
Expand Down
Loading
Loading