@knime/hub-features 1.22.4 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ # KNIME® Hub Auth Utils
2
+
3
+ The auth utils can be used to implement authentication in the hub. The idea is to use them in conjunction with the [knime-hub-auth-rely] service. Please check the service docs for detailed infos about the authentication flow.
4
+
5
+ [knime-hub-auth-rely]: https://github.com/knime/knime-hub-auth-rely
6
+
7
+ ## Common usage pattern
8
+
9
+ First you need to setup the auth refresher. This will make sure that the auth identity information
10
+ is fetched on critical authenticated paths. Normally, this means as part of a `vue-router` route guard
11
+ or a `Nuxt` middleware.
12
+
13
+ ```ts
14
+ /// router.ts
15
+ import { authUtils } from "@knime/hub-features";
16
+
17
+ const myMiddleware = (): NavigationGuard => {
18
+ // 1. First we create the refresher. This returns the function that
19
+ // schedules running the token refresher periodically
20
+ const authRefresher = authUtils.createAuthRefresher({
21
+ // `getIdentity` is just a fetch function that hits the API to get the
22
+ // account information; in a nutshell a user `id` and a `name`
23
+ getIdentity,
24
+ });
25
+
26
+ // 2. Here we're now returning the _actual_ navigation guard
27
+ return async () => {
28
+ try {
29
+ await authRefresher();
30
+ return true; // allow navigation
31
+ } catch (error) {
32
+ consola.error("Navigation middleware error", error);
33
+ return false; // cancel navigation
34
+ }
35
+ };
36
+ };
37
+
38
+ const router = createRouter({
39
+ history: createWebHistory(base),
40
+ routes: [
41
+ {
42
+ path: "/",
43
+ name: "Home",
44
+ component: HomeRouteComponent,
45
+ // 3. Simply setup your middleware for usage
46
+ beforeEnter: [myMiddleware()],
47
+
48
+ // 4. A simpler usage could have been
49
+ // beforeEnter: [authUtils.createAuthRefresher({ getIdentity })],
50
+ },
51
+ ],
52
+ });
53
+ ```
54
+
55
+ ### Handle auth errors
56
+
57
+ Another common pattern you'll need is to react to authentication errors (401/403) on your requests. There's different ways
58
+ to do this depending on the setup, but it usually revolves around setting up some HTTP request interceptor that runs some
59
+ code upon receveing a 401 (and optionally sometimes a 403).
60
+
61
+ Below is an example of an `ofetch` interceptor and how you would use these utils for such a case
62
+
63
+ ```ts
64
+ // interceptor.ts
65
+ import { authUtils } from "@knime/hub-features";
66
+
67
+ export const unauthorizedInterceptor: HttpInterceptor = {
68
+ response: {
69
+ onError: ({ response }) => {
70
+ if (response?.status === 401) {
71
+ // This function performs the redirection automatically.
72
+ authUtils.client.navigateToLogin({ wdywtg: window.location.href });
73
+
74
+ throw new Error("Auth failure");
75
+ }
76
+ },
77
+ },
78
+ };
79
+ ```
80
+
81
+ However, in the above example, the usage of `authUtils.client.navigateToLogin` is client-side code, which uses the `window` object to
82
+ redirect. Therefore, this doesn't work on SSR. If you need a server-side redirect, or if you want more control of _how_ you redirect (e.g using Nuxt's `navigateTo` function)
83
+ you can instead call another util which returns the login url and you can perform the redirection on your own:
84
+
85
+ ```ts
86
+ // some-file.ts
87
+
88
+ import { authUtils } from "@knime/hub-features";
89
+
90
+ const someHandler = () => {
91
+ // ...
92
+ const loginPath = authUtils.paths.login({ wdywtg: to.fullPath });
93
+ return navigateTo(loginPath, {
94
+ external: true,
95
+ redirectCode: TEMPORARY_REDIRECT,
96
+ });
97
+
98
+ // ...
99
+ };
100
+ ```
101
+
102
+ ### Accessing auth state
103
+
104
+ With the middleware in place, you can now reference the auth state in your app by making use of the provided stateful composable:
105
+
106
+ ```ts
107
+ /// some-file.ts
108
+ import { authUtils } from "@knime/hub-features";
109
+
110
+ const { loggedInUser, isLoggedIn } = authUtils.useAuthState();
111
+ ```
112
+
113
+ This state is immutable and is owned and controlled by the auth refresher internally.
114
+
115
+ ### Stop refreshing the auth token
116
+
117
+ In addition to the route middleware setup, you might run into cases where you want to stop the refresher manually. For this, simply import the corresponding function and call it:
118
+
119
+ ```ts
120
+ import { authUtils } from "@knime/hub-features";
121
+
122
+ authUtils.stopTokenRefresh();
123
+ ```
@@ -0,0 +1,18 @@
1
+ import { logger } from "./logger";
2
+ import { buildLoginPath, buildLogoutPath } from "./shared";
3
+
4
+ /**
5
+ * Navigates to the login path. This function does not work on SSR
6
+ */
7
+ export const navigateToLogin = ({ wdywtg }: { wdywtg?: string } = {}) => {
8
+ logger().debug("Navigating to login");
9
+ window.location.href = buildLoginPath({ wdywtg }); // NOSONAR - intended window usage
10
+ };
11
+
12
+ /**
13
+ * Navigates to the logout path. This function does not work on SSR
14
+ */
15
+ export const navigateToLogout = ({ wdywtg }: { wdywtg?: string } = {}) => {
16
+ logger().debug("Navigating to logout");
17
+ window.location.href = buildLogoutPath({ wdywtg }); // NOSONAR - intended window usage
18
+ };
@@ -0,0 +1,61 @@
1
+ import * as client from "./client";
2
+ import { logger } from "./logger";
3
+ import { startRefresher, stopRefresher } from "./refresher";
4
+ import { buildLoginPath, buildLogoutPath } from "./shared";
5
+ import type { AuthRefresher } from "./types";
6
+ import { setLoggedInUser, useAuthState } from "./useAuthState";
7
+
8
+ /**
9
+ * This function does two main things:
10
+ * 1. Fetch the user identity and store it via the auth composable.
11
+ * 2. Start the background refresher which will renew tokens according to the
12
+ * auth service TTL.
13
+ *
14
+ * Typical usage is from a route middleware for routes that require
15
+ * authentication. See README for a more in-depth example usage
16
+ *
17
+ * @param options AuthRefresher
18
+ * @returns a function that, when called, will load the identity (if needed)
19
+ * and start the token refresher. The returned function will throw if the
20
+ * underlying identity fetch fails.
21
+ */
22
+ function createAuthRefresher(options: AuthRefresher) {
23
+ return async () => {
24
+ const { isLoggedIn } = useAuthState();
25
+
26
+ if (!isLoggedIn.value) {
27
+ try {
28
+ const identity = await options.getIdentity();
29
+
30
+ if (identity) {
31
+ logger().info("Fetched user identity", {
32
+ id: identity.id,
33
+ name: identity.name,
34
+ });
35
+
36
+ setLoggedInUser(identity);
37
+ startRefresher({ onRefreshComplete: options.onRefreshComplete });
38
+ } else {
39
+ logger().error("Logged in, but could not retrieve logged in user");
40
+ }
41
+ } catch (e) {
42
+ logger().error(
43
+ "Not logged in, http request interceptor should redirect to login",
44
+ e,
45
+ );
46
+ throw e;
47
+ }
48
+ }
49
+ };
50
+ }
51
+
52
+ export const authUtils = {
53
+ createAuthRefresher,
54
+ stopTokenRefresh: stopRefresher,
55
+ useAuthState,
56
+ /**
57
+ * These utilities don't work in SSR. You can leverage the `paths` helpers to redirect manually
58
+ */
59
+ client,
60
+ paths: { login: buildLoginPath, logout: buildLogoutPath },
61
+ };
@@ -0,0 +1 @@
1
+ export const logger = () => consola.withTag("Auth Utils");
@@ -0,0 +1,65 @@
1
+ import { navigateToLogout } from "./client";
2
+ import { logger } from "./logger";
3
+ import { AUTH_SERVICE_PATH } from "./shared";
4
+ import type { AuthRefresher } from "./types";
5
+
6
+ type Options = Pick<AuthRefresher, "onRefreshComplete">;
7
+
8
+ const REFRESH_BUFFER = 10;
9
+ /**
10
+ * Subtracts a random noise value from the ttl to refresh the token before it actually expires.
11
+ *
12
+ * @param {number} ttlSeconds - time-to-live (TTL) in seconds
13
+ */
14
+ const subtractRandomNoise = (ttlSeconds: number) => {
15
+ const randomBuffer = Math.random() * (REFRESH_BUFFER / 2); // NOSONAR using random numbers is safe here
16
+ return Math.floor((ttlSeconds - REFRESH_BUFFER - randomBuffer) * 1000);
17
+ };
18
+
19
+ let timeout: number | NodeJS.Timeout;
20
+
21
+ /**
22
+ * Start proactively refreshing the token before it expires.
23
+ */
24
+ export const startRefresher = (options: Options = {}) => {
25
+ logger().debug("Starting token refresher");
26
+
27
+ globalThis.clearTimeout(timeout);
28
+
29
+ const fetchData = async () => {
30
+ try {
31
+ const response = await fetch(`${AUTH_SERVICE_PATH}/refresh`);
32
+
33
+ if (!response.ok) {
34
+ throw new Error(`Error during auth refresh: ${response.status}`);
35
+ }
36
+
37
+ options?.onRefreshComplete?.();
38
+ const { expiry } = await response.json();
39
+
40
+ const msUntilRefresh = subtractRandomNoise(expiry);
41
+
42
+ logger().debug(
43
+ `Token is valid. Expires in ${expiry}s, refreshing in ${msUntilRefresh / 1000}s`,
44
+ );
45
+
46
+ timeout = globalThis.setTimeout(() => {
47
+ // eslint-disable-next-line no-void
48
+ void fetchData();
49
+ }, msUntilRefresh);
50
+ } catch (error) {
51
+ logger().warn(`Token refresh failed: ${error}, logging out`);
52
+ navigateToLogout({ wdywtg: "/?authError=refresh" });
53
+ }
54
+ };
55
+
56
+ // eslint-disable-next-line no-void
57
+ void fetchData();
58
+ };
59
+
60
+ export const stopRefresher = () => {
61
+ if (timeout !== undefined) {
62
+ logger().info("Stopping auth refresh timer");
63
+ globalThis.clearTimeout(timeout);
64
+ }
65
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Adds a "where do you want to go" `wdywtg` query param to the url. This
3
+ * will take the user to the URL contained within that param after the auth flow completes
4
+ */
5
+ const addWdywtg = (url: string, wdywtg?: string) => {
6
+ if (wdywtg) {
7
+ const decodedWdywtg = decodeURIComponent(wdywtg);
8
+
9
+ // we decode an already-encoded wdywtg so that we can properly encode spaces
10
+ if (decodedWdywtg !== wdywtg) {
11
+ wdywtg = decodedWdywtg;
12
+ }
13
+
14
+ url += `?wdywtg=${encodeURIComponent(wdywtg)}`;
15
+ }
16
+ return url;
17
+ };
18
+
19
+ export const AUTH_SERVICE_PATH = "/_/auth";
20
+
21
+ export const buildLoginPath = ({ wdywtg }: { wdywtg?: string } = {}) =>
22
+ addWdywtg(`${AUTH_SERVICE_PATH}/login`, wdywtg);
23
+
24
+ export const buildLogoutPath = ({ wdywtg }: { wdywtg?: string } = {}) =>
25
+ addWdywtg(`${AUTH_SERVICE_PATH}/logout`, wdywtg);
@@ -0,0 +1,12 @@
1
+ export type UserIdentity = { id: string; name: string };
2
+
3
+ export type AuthRefresher = {
4
+ /**
5
+ * Fetcher function to obtain the basic user identity information
6
+ */
7
+ getIdentity: () => Promise<UserIdentity>;
8
+ /**
9
+ * Callback that runs after each completion of an auth token refresh
10
+ */
11
+ onRefreshComplete?: () => unknown;
12
+ };
@@ -0,0 +1,19 @@
1
+ import { computed, readonly, shallowRef } from "vue";
2
+
3
+ import type { UserIdentity } from "./types";
4
+
5
+ // Value is cached in the module scope
6
+ const loggedInUser = shallowRef<UserIdentity | null>(null);
7
+
8
+ export const setLoggedInUser = (user: UserIdentity) => {
9
+ loggedInUser.value = user;
10
+ };
11
+
12
+ export const useAuthState = () => {
13
+ const isLoggedIn = computed(() => Boolean(loggedInUser.value));
14
+
15
+ return {
16
+ loggedInUser: readonly(loggedInUser),
17
+ isLoggedIn,
18
+ };
19
+ };
@@ -70,18 +70,28 @@ export const useVersionsApi = ({
70
70
  }>;
71
71
  };
72
72
 
73
- const fetchResourceLabels = ({
73
+ const fetchResourceLabels = async ({
74
74
  resourceType,
75
75
  resourceId,
76
76
  }: {
77
77
  resourceType: "savepoint";
78
78
  resourceId: string;
79
- }) => {
80
- return doHubRequest(
81
- `/validation/validation/resources/${resourceType}/${resourceId}/labels`,
82
- ).catch(() => ({ assignedLabels: [] })) as Promise<{
83
- assignedLabels: Array<AssignedLabel>;
84
- }>;
79
+ }): Promise<{
80
+ assignedLabels: Array<AssignedLabel>;
81
+ }> => {
82
+ try {
83
+ return await doHubRequest(
84
+ `/validation/validation/resources/${resourceType}/${resourceId}/labels`,
85
+ );
86
+ } catch (error) {
87
+ consola.error("useVersionsApi::Failed to fetch resource labels", {
88
+ resourceId,
89
+ resourceType,
90
+ error,
91
+ });
92
+
93
+ return { assignedLabels: [] };
94
+ }
85
95
  };
86
96
 
87
97
  const deleteVersion = ({
@@ -140,36 +150,50 @@ export const useVersionsApi = ({
140
150
  }: {
141
151
  accountName: string;
142
152
  }): Promise<HubAvatarData> => {
143
- const accountInfo = await doHubRequest(`/accounts/name/${accountName}`, {
144
- headers: {
145
- Prefer: "representation=minimal",
146
- },
147
- });
153
+ try {
154
+ const accountInfo = await doHubRequest(`/accounts/name/${accountName}`, {
155
+ headers: {
156
+ Prefer: "representation=minimal",
157
+ },
158
+ });
148
159
 
149
- return {
150
- kind: accountInfo.type === "TEAM" ? "group" : "account",
151
- name: accountInfo.name,
152
- image: {
153
- url: accountInfo.avatarUrl,
154
- altText: `${accountInfo.realName ?? accountInfo.name} profile image`,
155
- },
156
- };
160
+ return {
161
+ kind: accountInfo.type === "TEAM" ? "group" : "account",
162
+ name: accountInfo.name,
163
+ image: {
164
+ url: accountInfo.avatarUrl,
165
+ altText: `${accountInfo.realName ?? accountInfo.name} profile image`,
166
+ },
167
+ };
168
+ } catch (error) {
169
+ consola.error("useVersionsApi::Failed to fetch user avatar", {
170
+ accountName,
171
+ error,
172
+ });
173
+
174
+ return {
175
+ kind: "account",
176
+ name: "?",
177
+ tooltip: "unknown",
178
+ };
179
+ }
157
180
  };
158
181
 
159
182
  const loadSavepointMetadata = async (
160
183
  savepoint: ItemSavepoint,
161
184
  ): Promise<WithAvatar & WithLabels> => {
162
- return {
163
- avatar: await getAvatar({
164
- accountName: savepoint.version?.author ?? savepoint.author,
165
- }),
166
- labels: savepoint.itemVersionId
167
- ? await fetchResourceLabels({
168
- resourceType: "savepoint",
169
- resourceId: savepoint.itemVersionId,
170
- }).then((response) => response.assignedLabels)
171
- : [],
172
- };
185
+ const avatar = await getAvatar({
186
+ accountName: savepoint.version?.author ?? savepoint.author,
187
+ });
188
+
189
+ const labels = savepoint.itemVersionId
190
+ ? await fetchResourceLabels({
191
+ resourceType: "savepoint",
192
+ resourceId: savepoint.itemVersionId,
193
+ }).then((response) => response.assignedLabels)
194
+ : [];
195
+
196
+ return { avatar, labels };
173
197
  };
174
198
 
175
199
  const fetchItemSavepoints = ({
@@ -35,9 +35,14 @@ export type EmbeddingContext = {
35
35
  */
36
36
  userIdleTimeout?: number;
37
37
  /**
38
- * Whether the embedded application can send analytic and tracking events
38
+ * Configuration for analytics
39
39
  */
40
- enableAnalytics?: boolean;
40
+ analytics?: {
41
+ /**
42
+ * Whether the embedded application can send analytic and tracking events
43
+ */
44
+ enabled: boolean;
45
+ };
41
46
  };
42
47
 
43
48
  type ShowNotificationEvent = {
package/src/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./useFileUpload";
3
3
  export * from "./useDownloadArtifact";
4
4
  export * from "./components/avatars";
5
5
  export * from "./embeddingSDK";
6
+ export * from "./authUtils";