@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.
- package/CHANGELOG.md +19 -0
- package/package.json +10 -5
- package/src/analytics/README.md +87 -0
- package/src/analytics/analytics.ts +76 -0
- package/src/analytics/index.ts +2 -0
- package/src/analytics/schema/schema.d.ts +129 -0
- package/src/analytics/schema/schema.json +472 -0
- package/src/analytics/types.ts +62 -0
- package/src/analytics/utils/eventIds.ts +35 -0
- package/src/analytics/utils/toSnakeCaseDeep.ts +33 -0
- package/src/authUtils/README.md +123 -0
- package/src/authUtils/client.ts +18 -0
- package/src/authUtils/index.ts +61 -0
- package/src/authUtils/logger.ts +1 -0
- package/src/authUtils/refresher.ts +65 -0
- package/src/authUtils/shared.ts +25 -0
- package/src/authUtils/types.ts +12 -0
- package/src/authUtils/useAuthState.ts +19 -0
- package/src/components/versions/composables/useVersionsApi.ts +55 -31
- package/src/embeddingSDK/types.ts +7 -2
- package/src/index.ts +1 -0
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
try {
|
|
154
|
+
const accountInfo = await doHubRequest(`/accounts/name/${accountName}`, {
|
|
155
|
+
headers: {
|
|
156
|
+
Prefer: "representation=minimal",
|
|
157
|
+
},
|
|
158
|
+
});
|
|
148
159
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
*
|
|
38
|
+
* Configuration for analytics
|
|
39
39
|
*/
|
|
40
|
-
|
|
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