diff --git a/.changeset/small-mice-crash.md b/.changeset/small-mice-crash.md new file mode 100644 index 00000000..99118f17 --- /dev/null +++ b/.changeset/small-mice-crash.md @@ -0,0 +1,5 @@ +--- +"@open-pioneer/authentication-keycloak": minor +--- + +Update Keycloak JavaScript adapter to v25. diff --git a/.changeset/three-parents-joke.md b/.changeset/three-parents-joke.md new file mode 100644 index 00000000..64f4f7fb --- /dev/null +++ b/.changeset/three-parents-joke.md @@ -0,0 +1,5 @@ +--- +"@open-pioneer/authentication-keycloak": patch +--- + +Refactor: use reactivity API internally to maintain the current state. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 564a791c..fd301dae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: src/packages/authentication-keycloak: dependencies: + '@conterra/reactivity-core': + specifier: ^0.4.0 + version: 0.4.0 '@open-pioneer/authentication': specifier: workspace:^ version: link:../authentication @@ -213,8 +216,8 @@ importers: specifier: workspace:^ version: link:../runtime keycloak-js: - specifier: ^23.0.7 - version: 23.0.7 + specifier: ^25.0.2 + version: 25.0.2 devDependencies: core-packages: specifier: workspace:^ @@ -3100,8 +3103,8 @@ packages: js-cookie@2.2.1: resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} - js-sha256@0.10.1: - resolution: {integrity: sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==} + js-sha256@0.11.0: + resolution: {integrity: sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3146,8 +3149,8 @@ packages: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} - keycloak-js@23.0.7: - resolution: {integrity: sha512-OmszsKzBhhm5yP4W1q/tMd+nNnKpOAdeVYcoGhphlv8Fj1bNk4wRTYzp7pn5BkvueLz7fhvKHz7uOc33524YrA==} + keycloak-js@25.0.2: + resolution: {integrity: sha512-ACLf5O5PqzfDJwGqvLpqM0kflYWmyl3+T7M2C23gztJYccDxdfNP54+B9OkXz2GnDpLUId0ceoA+lbHw9t4Wng==} kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} @@ -7462,7 +7465,7 @@ snapshots: js-cookie@2.2.1: {} - js-sha256@0.10.1: {} + js-sha256@0.11.0: {} js-tokens@4.0.0: {} @@ -7506,10 +7509,9 @@ snapshots: jwt-decode@4.0.0: {} - keycloak-js@23.0.7: + keycloak-js@25.0.2: dependencies: - base64-js: 1.5.1 - js-sha256: 0.10.1 + js-sha256: 0.11.0 jwt-decode: 4.0.0 kind-of@6.0.3: {} diff --git a/src/packages/authentication-keycloak/KeycloakAuthPlugin.ts b/src/packages/authentication-keycloak/KeycloakAuthPlugin.ts index 1938ba93..be8fa473 100644 --- a/src/packages/authentication-keycloak/KeycloakAuthPlugin.ts +++ b/src/packages/authentication-keycloak/KeycloakAuthPlugin.ts @@ -1,18 +1,19 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 +import { reactive, watch } from "@conterra/reactivity-core"; import { AuthPlugin, AuthPluginEvents, AuthState, LoginBehavior } from "@open-pioneer/authentication"; -import { EventEmitter, createLogger } from "@open-pioneer/core"; +import { EventEmitter, Resource, createLogger, destroyResource } from "@open-pioneer/core"; import { NotificationService } from "@open-pioneer/notifier"; import { + PackageIntl, Service, ServiceOptions, - type DECLARE_SERVICE_INTERFACE, - PackageIntl + type DECLARE_SERVICE_INTERFACE } from "@open-pioneer/runtime"; import Keycloak, { type KeycloakConfig, @@ -34,9 +35,6 @@ export class KeycloakAuthPlugin { declare [DECLARE_SERVICE_INTERFACE]: "authentication-keycloak.KeycloakAuthPlugin"; - #state: AuthState = { - kind: "pending" - }; #notifier: NotificationService; #intl: PackageIntl; #keycloakOptions: KeycloakOptions; @@ -45,6 +43,11 @@ export class KeycloakAuthPlugin #loginOptions: KeycloakLoginOptions; // eslint-disable-next-line @typescript-eslint/no-explicit-any #timerId: any; + #watcher: Resource | undefined; + + #state = reactive({ + kind: "pending" + }); constructor(options: ServiceOptions) { super(); @@ -53,6 +56,14 @@ export class KeycloakAuthPlugin this.#logoutOptions = { redirectUri: undefined }; this.#loginOptions = { redirectUri: undefined }; + // Backwards compatibility: emit "changed" event when the state changes + this.#watcher = watch( + () => [this.#state.value], + () => { + this.emit("changed"); + } + ); + try { this.#keycloakOptions = getKeycloakConfig(options.properties); } catch (e) { @@ -65,14 +76,15 @@ export class KeycloakAuthPlugin throw new Error("Failed to construct keycloak instance", { cause: e }); } this.#init().catch((e) => { + // TODO: Handle error + // // Stay in pending state when an error happens. // There is currently no useful way to signal an error using the plugin API, // going into 'not-authenticated' could lead to unexpected behavior (e.g. redirect loops). // See https://github.com/open-pioneer/trails-core-packages/issues/47 - this.#state = { + this.#updateState({ kind: "pending" - }; - this.emit("changed"); + }); this.#notifier.notify({ level: "error", title: this.#intl.formatMessage({ @@ -89,11 +101,12 @@ export class KeycloakAuthPlugin destroy() { clearInterval(this.#timerId); + this.#watcher = destroyResource(this.#watcher); this.#timerId = undefined; } getAuthState(): AuthState { - return this.#state; + return this.#state.value; } getLoginBehavior(): LoginBehavior { @@ -128,7 +141,7 @@ export class KeycloakAuthPlugin } if (isAuthenticated) { - this.#state = { + this.#updateState({ kind: "authenticated", sessionInfo: { userId: this.#keycloak.subject ? this.#keycloak.subject : "undefined", @@ -140,8 +153,7 @@ export class KeycloakAuthPlugin userName: this.#keycloak.idTokenParsed?.preferred_username } } - }; - this.emit("changed"); + }); LOG.debug(`User ${this.#keycloak.subject} is authenticated`); @@ -150,10 +162,9 @@ export class KeycloakAuthPlugin this.__refresh(refreshOptions.interval, refreshOptions.timeLeft); } } else { - this.#state = { + this.#updateState({ kind: "not-authenticated" - }; - this.emit("changed"); + }); LOG.debug("User is not authenticated"); } } @@ -164,14 +175,18 @@ export class KeycloakAuthPlugin this.#timerId = setInterval(() => { this.#keycloak.updateToken(timeLeft).catch((e) => { LOG.error("Failed to refresh token", e); - this.#state = { + this.#updateState({ kind: "not-authenticated" - }; + }); this.emit("changed"); this.destroy(); }); }, interval); } + + #updateState(newState: AuthState) { + this.#state.value = newState; + } } const DEFAULT_AUTO_REFRESH_OPT = { diff --git a/src/packages/authentication-keycloak/package.json b/src/packages/authentication-keycloak/package.json index a8226e59..47162ffe 100644 --- a/src/packages/authentication-keycloak/package.json +++ b/src/packages/authentication-keycloak/package.json @@ -17,11 +17,12 @@ "build": "build-pioneer-package" }, "peerDependencies": { + "@conterra/reactivity-core": "^0.4.0", "@open-pioneer/authentication": "workspace:^", "@open-pioneer/runtime": "workspace:^", "@open-pioneer/core": "workspace:^", "@open-pioneer/notifier": "workspace:^", - "keycloak-js": "^23.0.7" + "keycloak-js": "^25.0.2" }, "devDependencies": { "core-packages": "workspace:^" diff --git a/src/packages/reactivity/README.md b/src/packages/reactivity/README.md index 6807faed..c22ae787 100644 --- a/src/packages/reactivity/README.md +++ b/src/packages/reactivity/README.md @@ -77,6 +77,20 @@ export class Model { } ``` +#### Rendering a single reactive value + +If you have a single signal at hand, there is no need to use the more complex `useReactiveSnapshot` hook. +You can use the more primitive `useReactiveValue` hook instead: + +```jsx +import { useReactiveValue } from "@open-pioneer/reactivity"; + +function YourComponent({ signal }) { + const value = useReactiveValue(signal); // Subscribes to `signal.value` and re-renders on changes. + return
{value}
; +} +``` + More details are available in this package's API documentation and the [README of @conterra/reactivity-core](https://www.npmjs.com/package/@conterra/reactivity-core). ### ESLint configuration diff --git a/src/packages/runtime/builtin-services/ApplicationContextImpl.ts b/src/packages/runtime/builtin-services/ApplicationContextImpl.ts index 3ee7d43c..18c0a3d3 100644 --- a/src/packages/runtime/builtin-services/ApplicationContextImpl.ts +++ b/src/packages/runtime/builtin-services/ApplicationContextImpl.ts @@ -48,6 +48,8 @@ export class ApplicationContextImpl implements ApplicationContext { } setLocale(locale: string): void { + // This restarts the application at the moment, so this.locale will _not_ be updated. + // Instead, we get a new application with a new application context. this.#changeLocale(locale); } diff --git a/src/samples/keycloak-sample/AppUI.tsx b/src/samples/keycloak-sample/AppUI.tsx index 0253bee0..b4eff604 100644 --- a/src/samples/keycloak-sample/AppUI.tsx +++ b/src/samples/keycloak-sample/AppUI.tsx @@ -50,7 +50,7 @@ export function AppUI() { The following environment properties should be set via vite (e.g. in{" "} - env.local): + .env.local): diff --git a/src/samples/keycloak-sample/README.md b/src/samples/keycloak-sample/README.md new file mode 100644 index 00000000..aee5051b --- /dev/null +++ b/src/samples/keycloak-sample/README.md @@ -0,0 +1,50 @@ +# keycloak-sample + +Testing keycloak locally with docker. + +## Docker container + +Create a local docker container, for example by using the following command: + +```bash +# Note: --rm will automatically delete your container when it exits +$ docker run --rm -it -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:latest start-dev +``` + +## Creating a client application + +Open and log in with `admin` / `admin`. + +Visit the "Clients" section of the admin console and click "Create client": + +![New Client](./testing/New_Client_1.png) + +Authentication can be left to default settings: + +![Client Settings](./testing/New_Client_2.png) + +Configure the URLs of your local application. +We're using the path to the keycloak sample app in the following example. + +Note that the Vite server must run on the exact port used here (you can change the vite config to force a specific port, if necessary). + +![Client URLs](./testing/New_Client_3.png) + +Finally, save the new client configuration. + +## Configuring the sample app + +Create a file called `.env.local` in the root of this repository with the following content: + +``` +VITE_KEYCLOAK_CONFIG_URL=http://localhost:8080 +VITE_KEYCLOAK_CONFIG_REALM=master +VITE_KEYCLOAK_CONFIG_CLIENT_ID=trails +``` + +Afterwards, restart vite and open the keycloak sample app in your browser. +It should now redirect you to your local keycloak and back to the sample app once you are authenticated. + +![Authenticated](./testing/Authenticated.png) + +Clicking the "Logout" button should end the session. diff --git a/src/samples/keycloak-sample/testing/Authenticated.png b/src/samples/keycloak-sample/testing/Authenticated.png new file mode 100644 index 00000000..2e0e4f00 Binary files /dev/null and b/src/samples/keycloak-sample/testing/Authenticated.png differ diff --git a/src/samples/keycloak-sample/testing/New_Client_1.png b/src/samples/keycloak-sample/testing/New_Client_1.png new file mode 100644 index 00000000..c4d6c54b Binary files /dev/null and b/src/samples/keycloak-sample/testing/New_Client_1.png differ diff --git a/src/samples/keycloak-sample/testing/New_Client_2.png b/src/samples/keycloak-sample/testing/New_Client_2.png new file mode 100644 index 00000000..c18c071c Binary files /dev/null and b/src/samples/keycloak-sample/testing/New_Client_2.png differ diff --git a/src/samples/keycloak-sample/testing/New_Client_3.png b/src/samples/keycloak-sample/testing/New_Client_3.png new file mode 100644 index 00000000..a21043bd Binary files /dev/null and b/src/samples/keycloak-sample/testing/New_Client_3.png differ