@matdata/yasgui 5.5.0 → 5.7.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.
Files changed (38) hide show
  1. package/build/ts/src/ConfigExportImport.js +3 -0
  2. package/build/ts/src/ConfigExportImport.js.map +1 -1
  3. package/build/ts/src/OAuth2Utils.d.ts +18 -0
  4. package/build/ts/src/OAuth2Utils.js +214 -0
  5. package/build/ts/src/OAuth2Utils.js.map +1 -0
  6. package/build/ts/src/PersistentConfig.d.ts +3 -0
  7. package/build/ts/src/PersistentConfig.js +7 -0
  8. package/build/ts/src/PersistentConfig.js.map +1 -1
  9. package/build/ts/src/Tab.d.ts +1 -1
  10. package/build/ts/src/Tab.js +116 -85
  11. package/build/ts/src/Tab.js.map +1 -1
  12. package/build/ts/src/TabSettingsModal.d.ts +1 -0
  13. package/build/ts/src/TabSettingsModal.js +330 -27
  14. package/build/ts/src/TabSettingsModal.js.map +1 -1
  15. package/build/ts/src/defaults.js +1 -1
  16. package/build/ts/src/defaults.js.map +1 -1
  17. package/build/ts/src/index.d.ts +21 -6
  18. package/build/ts/src/index.js +7 -1
  19. package/build/ts/src/index.js.map +1 -1
  20. package/build/ts/src/version.d.ts +1 -1
  21. package/build/ts/src/version.js +1 -1
  22. package/build/yasgui.min.css +1 -1
  23. package/build/yasgui.min.css.map +3 -3
  24. package/build/yasgui.min.js +185 -157
  25. package/build/yasgui.min.js.map +4 -4
  26. package/package.json +3 -2
  27. package/src/ConfigExportImport.ts +3 -0
  28. package/src/OAuth2Utils.ts +315 -0
  29. package/src/PersistentConfig.ts +10 -0
  30. package/src/Tab.ts +191 -111
  31. package/src/TabSettingsModal.scss +70 -3
  32. package/src/TabSettingsModal.ts +400 -30
  33. package/src/defaults.ts +1 -1
  34. package/src/endpointSelect.scss +12 -0
  35. package/src/index.ts +42 -10
  36. package/src/tab.scss +1 -0
  37. package/src/themes.scss +1 -0
  38. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@matdata/yasgui",
3
3
  "description": "Yet Another SPARQL GUI",
4
- "version": "5.5.0",
4
+ "version": "5.7.0",
5
5
  "main": "build/yasgui.min.js",
6
6
  "types": "build/ts/src/index.d.ts",
7
7
  "license": "MIT",
@@ -33,7 +33,8 @@
33
33
  "jsuri": "^1.3.1",
34
34
  "lodash-es": "^4.17.15",
35
35
  "sortablejs": "^1.10.2",
36
- "@matdata/yasgui-graph-plugin": "^1.0.0"
36
+ "@matdata/yasgui-graph-plugin": "^1.3.0",
37
+ "@matdata/yasgui-table-plugin": "^1.0.0"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/autosuggest-highlight": "^3.1.0",
@@ -305,6 +305,9 @@ export function parseFromTurtle(turtle: string): Partial<PersistedJson> {
305
305
  withCredentials: false,
306
306
  adjustQueryBeforeRequest: false,
307
307
  basicAuth: undefined,
308
+ bearerAuth: undefined,
309
+ apiKeyAuth: undefined,
310
+ oauth2Auth: undefined,
308
311
  },
309
312
  yasr: {
310
313
  settings: {},
@@ -0,0 +1,315 @@
1
+ /**
2
+ * OAuth 2.0 Utility Functions
3
+ * Handles OAuth 2.0 Authorization Code flow with PKCE
4
+ */
5
+
6
+ /**
7
+ * Generate a random string for PKCE code verifier
8
+ */
9
+ function generateRandomString(length: number): string {
10
+ const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
11
+ const values = crypto.getRandomValues(new Uint8Array(length));
12
+ return Array.from(values)
13
+ .map((v) => possible[v % possible.length])
14
+ .join("");
15
+ }
16
+
17
+ /**
18
+ * Generate SHA-256 hash and base64url encode it
19
+ */
20
+ async function sha256(plain: string): Promise<string> {
21
+ const encoder = new TextEncoder();
22
+ const data = encoder.encode(plain);
23
+ const hash = await crypto.subtle.digest("SHA-256", data);
24
+ return base64urlEncode(hash);
25
+ }
26
+
27
+ /**
28
+ * Base64url encode (without padding)
29
+ */
30
+ function base64urlEncode(buffer: ArrayBuffer): string {
31
+ const bytes = new Uint8Array(buffer);
32
+ let binary = "";
33
+ for (let i = 0; i < bytes.length; i++) {
34
+ binary += String.fromCharCode(bytes[i]);
35
+ }
36
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
37
+ }
38
+
39
+ export interface OAuth2Config {
40
+ clientId: string;
41
+ authorizationEndpoint: string;
42
+ tokenEndpoint: string;
43
+ redirectUri: string;
44
+ scope?: string;
45
+ }
46
+
47
+ export interface OAuth2TokenResponse {
48
+ access_token: string;
49
+ id_token?: string; // ID token for authentication (Azure AD, OIDC)
50
+ refresh_token?: string;
51
+ expires_in?: number; // seconds
52
+ token_type: string;
53
+ }
54
+
55
+ /**
56
+ * Check if a token has expired
57
+ */
58
+ export function isTokenExpired(tokenExpiry?: number): boolean {
59
+ if (!tokenExpiry) return true;
60
+ // Add 60 second buffer to refresh before actual expiration
61
+ return Date.now() >= tokenExpiry - 60000;
62
+ }
63
+
64
+ /**
65
+ * Start OAuth 2.0 Authorization Code flow with PKCE
66
+ * Opens a popup window for user authentication
67
+ */
68
+ export async function startOAuth2Flow(config: OAuth2Config): Promise<OAuth2TokenResponse> {
69
+ // Generate PKCE code verifier and challenge
70
+ const codeVerifier = generateRandomString(128);
71
+ const codeChallenge = await sha256(codeVerifier);
72
+
73
+ // Generate state for CSRF protection
74
+ const state = generateRandomString(32);
75
+
76
+ // Generate unique flow ID to prevent collisions across multiple OAuth flows
77
+ const flowId = generateRandomString(16);
78
+
79
+ // Store code verifier and state in sessionStorage for later retrieval with unique keys
80
+ sessionStorage.setItem(`oauth2_code_verifier_${flowId}`, codeVerifier);
81
+ sessionStorage.setItem(`oauth2_state_${flowId}`, state);
82
+
83
+ // Build authorization URL
84
+ const authUrl = new URL(config.authorizationEndpoint);
85
+ authUrl.searchParams.set("client_id", config.clientId);
86
+ authUrl.searchParams.set("response_type", "code");
87
+ authUrl.searchParams.set("redirect_uri", config.redirectUri);
88
+ authUrl.searchParams.set("code_challenge", codeChallenge);
89
+ authUrl.searchParams.set("code_challenge_method", "S256");
90
+ // Include flowId in state to link the callback to this specific flow
91
+ authUrl.searchParams.set("state", `${state}:${flowId}`);
92
+ if (config.scope) {
93
+ authUrl.searchParams.set("scope", config.scope);
94
+ }
95
+
96
+ // Open popup window
97
+ const width = 600;
98
+ const height = 700;
99
+ const left = window.screenX + (window.outerWidth - width) / 2;
100
+ const top = window.screenY + (window.outerHeight - height) / 2;
101
+ const popup = window.open(
102
+ authUrl.toString(),
103
+ "OAuth2 Authorization",
104
+ `width=${width},height=${height},left=${left},top=${top},popup=yes,scrollbars=yes`,
105
+ );
106
+
107
+ if (!popup) {
108
+ throw new Error("Failed to open OAuth 2.0 authorization popup. Please allow popups for this site.");
109
+ }
110
+
111
+ // Wait for the OAuth callback
112
+ return new Promise<OAuth2TokenResponse>((resolve, reject) => {
113
+ // Flag to prevent race condition between polling and messageHandler
114
+ let flowCompleted = false;
115
+
116
+ const cleanup = () => {
117
+ clearInterval(checkInterval);
118
+ window.removeEventListener("message", messageHandler);
119
+ // Clean up sessionStorage
120
+ sessionStorage.removeItem(`oauth2_code_verifier_${flowId}`);
121
+ sessionStorage.removeItem(`oauth2_state_${flowId}`);
122
+ };
123
+
124
+ const checkInterval = setInterval(() => {
125
+ try {
126
+ // Check if popup is closed
127
+ if (popup.closed) {
128
+ if (!flowCompleted) {
129
+ flowCompleted = true;
130
+ cleanup();
131
+ reject(new Error("OAuth 2.0 authorization was cancelled"));
132
+ }
133
+ return;
134
+ }
135
+
136
+ // Try to read popup URL (will throw if cross-origin)
137
+ try {
138
+ const popupUrl = popup.location.href;
139
+ if (popupUrl.startsWith(config.redirectUri) && !flowCompleted) {
140
+ flowCompleted = true;
141
+ cleanup();
142
+ popup.close();
143
+
144
+ // Parse the authorization code from URL
145
+ const url = new URL(popupUrl);
146
+ const code = url.searchParams.get("code");
147
+ const returnedState = url.searchParams.get("state");
148
+ const error = url.searchParams.get("error");
149
+ const errorDescription = url.searchParams.get("error_description");
150
+
151
+ if (error) {
152
+ reject(new Error(`OAuth 2.0 error: ${error}${errorDescription ? " - " + errorDescription : ""}`));
153
+ return;
154
+ }
155
+
156
+ if (!code) {
157
+ reject(new Error("No authorization code received"));
158
+ return;
159
+ }
160
+
161
+ // Validate state includes our flowId
162
+ const expectedState = `${state}:${flowId}`;
163
+ if (returnedState !== expectedState) {
164
+ reject(new Error("State mismatch - possible CSRF attack"));
165
+ return;
166
+ }
167
+
168
+ // Exchange code for tokens
169
+ exchangeCodeForToken(config, code, codeVerifier).then(resolve).catch(reject);
170
+ }
171
+ } catch (e) {
172
+ // Cross-origin error is expected when on OAuth provider's domain
173
+ // Keep waiting
174
+ }
175
+ } catch (e) {
176
+ // Ignore errors while checking popup
177
+ }
178
+ }, 500);
179
+
180
+ // Also listen for postMessage from redirect page (alternative method)
181
+ const messageHandler = (event: MessageEvent) => {
182
+ // Validate origin if needed (should match redirect URI origin)
183
+ if (event.data && event.data.type === "oauth2_callback" && !flowCompleted) {
184
+ flowCompleted = true;
185
+ cleanup();
186
+ if (popup && !popup.closed) {
187
+ popup.close();
188
+ }
189
+
190
+ const { code, state: returnedState, error, error_description } = event.data;
191
+
192
+ if (error) {
193
+ reject(new Error(`OAuth 2.0 error: ${error}${error_description ? " - " + error_description : ""}`));
194
+ return;
195
+ }
196
+
197
+ if (!code) {
198
+ reject(new Error("No authorization code received"));
199
+ return;
200
+ }
201
+
202
+ // Validate state includes our flowId
203
+ const expectedState = `${state}:${flowId}`;
204
+ if (returnedState !== expectedState) {
205
+ reject(new Error("State mismatch - possible CSRF attack"));
206
+ return;
207
+ }
208
+
209
+ // Exchange code for tokens
210
+ exchangeCodeForToken(config, code, codeVerifier).then(resolve).catch(reject);
211
+ }
212
+ };
213
+
214
+ window.addEventListener("message", messageHandler);
215
+
216
+ // Cleanup timeout after 5 minutes
217
+ setTimeout(
218
+ () => {
219
+ if (!flowCompleted) {
220
+ flowCompleted = true;
221
+ cleanup();
222
+ if (popup && !popup.closed) {
223
+ popup.close();
224
+ }
225
+ reject(new Error("OAuth 2.0 authorization timeout"));
226
+ }
227
+ },
228
+ 5 * 60 * 1000,
229
+ );
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Exchange authorization code for access token
235
+ */
236
+ async function exchangeCodeForToken(
237
+ config: OAuth2Config,
238
+ code: string,
239
+ codeVerifier: string,
240
+ ): Promise<OAuth2TokenResponse> {
241
+ const tokenUrl = config.tokenEndpoint;
242
+
243
+ const body = new URLSearchParams();
244
+ body.set("grant_type", "authorization_code");
245
+ body.set("code", code);
246
+ body.set("redirect_uri", config.redirectUri);
247
+ body.set("client_id", config.clientId);
248
+ body.set("code_verifier", codeVerifier);
249
+
250
+ try {
251
+ const response = await fetch(tokenUrl, {
252
+ method: "POST",
253
+ headers: {
254
+ "Content-Type": "application/x-www-form-urlencoded",
255
+ },
256
+ body: body.toString(),
257
+ });
258
+
259
+ if (!response.ok) {
260
+ const errorText = await response.text();
261
+ throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`);
262
+ }
263
+
264
+ const tokenResponse: OAuth2TokenResponse = await response.json();
265
+
266
+ // Note: sessionStorage cleanup is handled by the caller
267
+ return tokenResponse;
268
+ } catch (error) {
269
+ // Note: sessionStorage cleanup is handled by the caller
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Refresh an OAuth 2.0 access token using a refresh token
276
+ */
277
+ export async function refreshOAuth2Token(
278
+ config: Omit<OAuth2Config, "authorizationEndpoint" | "redirectUri">,
279
+ refreshToken: string,
280
+ ): Promise<OAuth2TokenResponse> {
281
+ const tokenUrl = config.tokenEndpoint;
282
+
283
+ const body = new URLSearchParams();
284
+ body.set("grant_type", "refresh_token");
285
+ body.set("refresh_token", refreshToken);
286
+ body.set("client_id", config.clientId);
287
+
288
+ try {
289
+ const response = await fetch(tokenUrl, {
290
+ method: "POST",
291
+ headers: {
292
+ "Content-Type": "application/x-www-form-urlencoded",
293
+ },
294
+ body: body.toString(),
295
+ });
296
+
297
+ if (!response.ok) {
298
+ const errorText = await response.text();
299
+ throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`);
300
+ }
301
+
302
+ const tokenResponse: OAuth2TokenResponse = await response.json();
303
+ return tokenResponse;
304
+ } catch (error) {
305
+ throw error;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Calculate token expiry timestamp from expires_in value
311
+ */
312
+ export function calculateTokenExpiry(expiresIn?: number): number | undefined {
313
+ if (!expiresIn) return undefined;
314
+ return Date.now() + expiresIn * 1000;
315
+ }
@@ -14,6 +14,7 @@ export interface PersistedJson {
14
14
  endpointConfigs?: EndpointConfig[]; // New endpoint-based storage with auth
15
15
  theme?: "light" | "dark";
16
16
  orientation?: "vertical" | "horizontal";
17
+ showSnippetsBar?: boolean;
17
18
  }
18
19
  function getDefaults(): PersistedJson {
19
20
  return {
@@ -166,6 +167,15 @@ export default class PersistentConfig {
166
167
  this.toStorage();
167
168
  }
168
169
 
170
+ public getShowSnippetsBar(): boolean | undefined {
171
+ return this.persistedJson.showSnippetsBar;
172
+ }
173
+
174
+ public setShowSnippetsBar(show: boolean) {
175
+ this.persistedJson.showSnippetsBar = show;
176
+ this.toStorage();
177
+ }
178
+
169
179
  // New endpoint configuration methods
170
180
  public getEndpointConfigs(): EndpointConfig[] {
171
181
  return this.persistedJson.endpointConfigs || [];