@matdata/yasgui 5.6.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.
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.6.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",
@@ -307,6 +307,7 @@ export function parseFromTurtle(turtle: string): Partial<PersistedJson> {
307
307
  basicAuth: undefined,
308
308
  bearerAuth: undefined,
309
309
  apiKeyAuth: undefined,
310
+ oauth2Auth: undefined,
310
311
  },
311
312
  yasr: {
312
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
+ }
package/src/Tab.ts CHANGED
@@ -9,6 +9,7 @@ import * as shareLink from "./linkUtils";
9
9
  import EndpointSelect from "./endpointSelect";
10
10
  import "./tab.scss";
11
11
  import { getRandomId, default as Yasgui, YasguiRequestConfig } from "./";
12
+ import * as OAuth2Utils from "./OAuth2Utils";
12
13
 
13
14
  // Layout orientation toggle icons
14
15
  const HORIZONTAL_LAYOUT_ICON = `<svg viewBox="0 0 24 24" class="svgImg">
@@ -477,9 +478,15 @@ export class Tab extends EventEmitter {
477
478
  public getName() {
478
479
  return this.persistentJson.name;
479
480
  }
480
-
481
- public query(): Promise<any> {
481
+ public async query(): Promise<any> {
482
482
  if (!this.yasqe) return Promise.reject(new Error("No yasqe editor initialized"));
483
+
484
+ // Check and refresh OAuth 2.0 token if needed
485
+ const tokenValid = await this.ensureOAuth2TokenValid();
486
+ if (!tokenValid) {
487
+ return Promise.reject(new Error("OAuth 2.0 authentication failed"));
488
+ }
489
+
483
490
  return this.yasqe.query();
484
491
  }
485
492
 
@@ -528,11 +535,85 @@ export class Tab extends EventEmitter {
528
535
  apiKey: auth.apiKey,
529
536
  },
530
537
  };
538
+ } else if (auth.type === "oauth2") {
539
+ // For OAuth 2.0, return the current access token and ID token
540
+ // Token refresh is handled separately before query execution
541
+ if (auth.accessToken) {
542
+ return {
543
+ type: "oauth2" as const,
544
+ config: {
545
+ accessToken: auth.accessToken,
546
+ idToken: auth.idToken,
547
+ },
548
+ };
549
+ }
531
550
  }
532
551
 
533
552
  return undefined;
534
553
  }
535
554
 
555
+ /**
556
+ * Check and refresh OAuth 2.0 token if needed
557
+ * Should be called before query execution
558
+ */
559
+ private async ensureOAuth2TokenValid(): Promise<boolean> {
560
+ const endpoint = this.getEndpoint();
561
+ if (!endpoint) return true;
562
+
563
+ const endpointConfig = this.yasgui.persistentConfig.getEndpointConfig(endpoint);
564
+ if (!endpointConfig || !endpointConfig.authentication) return true;
565
+
566
+ const auth = endpointConfig.authentication;
567
+ if (auth.type !== "oauth2") return true;
568
+
569
+ // Check if token is expired
570
+ if (OAuth2Utils.isTokenExpired(auth.tokenExpiry)) {
571
+ // Try to refresh the token if we have a refresh token
572
+ if (auth.refreshToken) {
573
+ try {
574
+ const tokenResponse = await OAuth2Utils.refreshOAuth2Token(
575
+ {
576
+ clientId: auth.clientId,
577
+ tokenEndpoint: auth.tokenEndpoint,
578
+ },
579
+ auth.refreshToken,
580
+ );
581
+
582
+ const tokenExpiry = OAuth2Utils.calculateTokenExpiry(tokenResponse.expires_in);
583
+
584
+ // Update stored authentication with new tokens
585
+ this.yasgui.persistentConfig.addOrUpdateEndpoint(endpoint, {
586
+ authentication: {
587
+ ...auth,
588
+ accessToken: tokenResponse.access_token,
589
+ idToken: tokenResponse.id_token,
590
+ refreshToken: tokenResponse.refresh_token || auth.refreshToken,
591
+ tokenExpiry,
592
+ },
593
+ });
594
+
595
+ return true;
596
+ } catch (error) {
597
+ console.error("Failed to refresh OAuth 2.0 token:", error);
598
+ // Token refresh failed, user needs to re-authenticate
599
+ alert(
600
+ "Your OAuth 2.0 session has expired and could not be refreshed. Please re-authenticate by clicking the Settings button (gear icon) and selecting the SPARQL Endpoints tab.",
601
+ );
602
+ return false;
603
+ }
604
+ } else {
605
+ // No refresh token available, user needs to re-authenticate
606
+ alert(
607
+ "Your OAuth 2.0 session has expired. Please re-authenticate by clicking the Settings button (gear icon) and selecting the SPARQL Endpoints tab.",
608
+ );
609
+ return false;
610
+ }
611
+ }
612
+
613
+ // Token is still valid
614
+ return true;
615
+ }
616
+
536
617
  /**
537
618
  * The Yasgui configuration object may contain a custom request config
538
619
  * This request config object can contain getter functions, or plain json
@@ -606,6 +687,8 @@ export class Tab extends EventEmitter {
606
687
  processedReqConfig.bearerAuth = endpointAuth.config;
607
688
  } else if (endpointAuth.type === "apiKey" && typeof processedReqConfig.apiKeyAuth === "undefined") {
608
689
  processedReqConfig.apiKeyAuth = endpointAuth.config;
690
+ } else if (endpointAuth.type === "oauth2" && typeof processedReqConfig.oauth2Auth === "undefined") {
691
+ processedReqConfig.oauth2Auth = endpointAuth.config;
609
692
  }
610
693
  }
611
694
 
@@ -63,7 +63,7 @@
63
63
  background: var(--yasgui-bg-primary, white);
64
64
  border-radius: 8px;
65
65
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
66
- max-width: 800px;
66
+ max-width: 1000px;
67
67
  width: 90%;
68
68
  max-height: 90vh;
69
69
  display: flex;
@@ -160,6 +160,13 @@
160
160
 
161
161
  .settingsSection {
162
162
  margin-bottom: 20px;
163
+
164
+ h3 {
165
+ margin: 0 0 12px 0;
166
+ font-size: 16px;
167
+ font-weight: 600;
168
+ color: var(--yasgui-text-primary, #000);
169
+ }
163
170
  }
164
171
 
165
172
  .settingsLabel {
@@ -297,7 +304,8 @@
297
304
  }
298
305
 
299
306
  .primaryButton,
300
- .secondaryButton {
307
+ .secondaryButton,
308
+ .dangerButton {
301
309
  padding: 8px 20px;
302
310
  border: none;
303
311
  border-radius: 4px;
@@ -320,7 +328,21 @@
320
328
  color: var(--yasgui-text-primary, #333);
321
329
 
322
330
  &:hover {
323
- background: var(--yasgui-border-color, #d0d0d0);
331
+ background: var(--yasgui-bg-quaternary, #d0d0d0);
332
+ }
333
+ }
334
+
335
+ .dangerButton {
336
+ background: var(--yasgui-danger-bg, #dc3545);
337
+ color: white;
338
+ margin-top: 10px;
339
+
340
+ &:hover {
341
+ background: var(--yasgui-danger-hover, #c82333);
342
+ }
343
+
344
+ &:active {
345
+ background: var(--yasgui-danger-active, #bd2130);
324
346
  }
325
347
  }
326
348
 
@@ -849,6 +871,24 @@
849
871
  }
850
872
  }
851
873
 
874
+ .authHelpLink {
875
+ padding: 10px 12px;
876
+ background: var(--yasgui-bg-secondary, #f5f5f5);
877
+ border: 1px solid var(--yasgui-border-color, #e0e0e0);
878
+ border-radius: 4px;
879
+ font-size: 13px;
880
+ margin-bottom: 12px;
881
+
882
+ a {
883
+ color: var(--yasgui-accent-color, #337ab7);
884
+ text-decoration: none;
885
+
886
+ &:hover {
887
+ text-decoration: underline;
888
+ }
889
+ }
890
+ }
891
+
852
892
  .authSecurityNotice {
853
893
  padding: 12px;
854
894
  background: var(--yasgui-warning-bg, #fff3cd);
@@ -925,6 +965,33 @@
925
965
  }
926
966
  }
927
967
 
968
+ // OAuth 2.0 specific styling
969
+ .authInputHelp {
970
+ font-size: 12px;
971
+ color: var(--yasgui-text-secondary, #666);
972
+ margin-top: 4px;
973
+ line-height: 1.4;
974
+ }
975
+
976
+ .oauth2TokenStatus {
977
+ padding: 8px 12px;
978
+ border-radius: 4px;
979
+ font-size: 14px;
980
+ font-weight: 500;
981
+
982
+ &.authenticated {
983
+ background: var(--yasgui-success-bg, #d4edda);
984
+ color: var(--yasgui-success-text, #155724);
985
+ border: 1px solid var(--yasgui-success-border, #c3e6cb);
986
+ }
987
+
988
+ &.expired {
989
+ background: var(--yasgui-warning-bg, #fff3cd);
990
+ color: var(--yasgui-warning-text, #856404);
991
+ border: 1px solid var(--yasgui-warning-border, #ffeaa7);
992
+ }
993
+ }
994
+
928
995
  // Add endpoint form styling
929
996
  .addEndpointForm {
930
997
  margin-top: 20px;