@decocms/mesh-sdk 1.1.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,742 @@
1
+ /**
2
+ * MCP OAuth Client Utilities
3
+ *
4
+ * Provides OAuth authentication flow for MCP servers.
5
+ * Uses the MCP SDK's auth module with in-memory storage.
6
+ */
7
+
8
+ import {
9
+ auth,
10
+ exchangeAuthorization,
11
+ discoverOAuthProtectedResourceMetadata,
12
+ discoverAuthorizationServerMetadata,
13
+ } from "@modelcontextprotocol/sdk/client/auth.js";
14
+ import type {
15
+ OAuthClientProvider,
16
+ AuthResult,
17
+ } from "@modelcontextprotocol/sdk/client/auth.js";
18
+ import type {
19
+ OAuthClientMetadata,
20
+ OAuthClientInformation,
21
+ OAuthClientInformationFull,
22
+ OAuthTokens,
23
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
24
+
25
+ /**
26
+ * Simple hash function for server URLs
27
+ */
28
+ function hashServerUrl(url: string): string {
29
+ let hash = 0;
30
+ for (let i = 0; i < url.length; i++) {
31
+ const char = url.charCodeAt(i);
32
+ hash = (hash << 5) - hash + char;
33
+ hash = hash & hash;
34
+ }
35
+ return Math.abs(hash).toString(16);
36
+ }
37
+
38
+ /**
39
+ * Global in-memory store for active OAuth sessions.
40
+ */
41
+ const activeOAuthSessions = new Map<string, McpOAuthProvider>();
42
+
43
+ /**
44
+ * Storage key prefix for OAuth callback fallback
45
+ */
46
+ const OAUTH_CALLBACK_STORAGE_KEY = "mcp:oauth:callback:";
47
+
48
+ /**
49
+ * Window mode for OAuth flow
50
+ * - "popup": Opens in a popup window (default, may be blocked on some devices)
51
+ * - "tab": Opens in a new tab (works on all devices, uses localStorage for communication)
52
+ */
53
+ export type OAuthWindowMode = "popup" | "tab";
54
+
55
+ /**
56
+ * Options for the MCP OAuth provider
57
+ */
58
+ export interface McpOAuthProviderOptions {
59
+ /** MCP server URL */
60
+ serverUrl: string;
61
+ /** OAuth client name */
62
+ clientName?: string;
63
+ /** OAuth client URI */
64
+ clientUri?: string;
65
+ /** OAuth callback URL */
66
+ callbackUrl?: string;
67
+ /** OAuth scopes to request (space-separated or array). If not provided, no scope is requested */
68
+ scope?: string | string[];
69
+ /** Window mode: "popup" (default) or "tab" (for devices that block popups) */
70
+ windowMode?: OAuthWindowMode;
71
+ }
72
+
73
+ /**
74
+ * MCP OAuth client provider using in-memory storage only.
75
+ * No localStorage or sessionStorage - everything is ephemeral.
76
+ */
77
+ class McpOAuthProvider implements OAuthClientProvider {
78
+ private serverUrl: string;
79
+ private _clientMetadata: OAuthClientMetadata;
80
+ private _redirectUrl: string;
81
+ private _windowMode: OAuthWindowMode;
82
+
83
+ // In-memory storage for OAuth flow data
84
+ private _state: string | null = null;
85
+ private _codeVerifier: string | null = null;
86
+ private _clientInfo: OAuthClientInformation | null = null;
87
+ private _tokens: OAuthTokens | null = null;
88
+
89
+ constructor(options: McpOAuthProviderOptions) {
90
+ this.serverUrl = options.serverUrl;
91
+ this._redirectUrl =
92
+ options.callbackUrl ?? `${window.location.origin}/oauth/callback`;
93
+ this._windowMode = options.windowMode ?? "popup";
94
+
95
+ // Build scope string if provided
96
+ const scopeStr = options.scope
97
+ ? Array.isArray(options.scope)
98
+ ? options.scope.join(" ")
99
+ : options.scope
100
+ : undefined;
101
+
102
+ this._clientMetadata = {
103
+ redirect_uris: [this._redirectUrl],
104
+ token_endpoint_auth_method: "none",
105
+ grant_types: ["authorization_code", "refresh_token"],
106
+ response_types: ["code"],
107
+ client_name: options.clientName ?? "@decocms/mesh MCP client",
108
+ // Only include scope if explicitly provided - some servers have their own scope requirements
109
+ ...(scopeStr && { scope: scopeStr }),
110
+ };
111
+
112
+ // Register this session for callback handling
113
+ activeOAuthSessions.set(hashServerUrl(this.serverUrl), this);
114
+ }
115
+
116
+ get redirectUrl(): string {
117
+ return this._redirectUrl;
118
+ }
119
+
120
+ get clientMetadata(): OAuthClientMetadata {
121
+ return this._clientMetadata;
122
+ }
123
+
124
+ state(): string {
125
+ if (!this._state) {
126
+ this._state = crypto.randomUUID();
127
+ }
128
+ return this._state;
129
+ }
130
+
131
+ getStoredState(): string | null {
132
+ return this._state;
133
+ }
134
+
135
+ clientInformation(): OAuthClientInformation | undefined {
136
+ return this._clientInfo ?? undefined;
137
+ }
138
+
139
+ saveClientInformation(clientInfo: OAuthClientInformationFull): void {
140
+ this._clientInfo = clientInfo;
141
+ }
142
+
143
+ tokens(): OAuthTokens | undefined {
144
+ return this._tokens ?? undefined;
145
+ }
146
+
147
+ saveTokens(tokens: OAuthTokens): void {
148
+ this._tokens = tokens;
149
+ }
150
+
151
+ redirectToAuthorization(authorizationUrl: URL): void {
152
+ if (this._windowMode === "tab") {
153
+ // Open in new tab - uses localStorage for cross-tab communication
154
+ const tab = window.open(authorizationUrl.toString(), "_blank");
155
+ if (!tab) {
156
+ // Fallback: navigate current window (will lose state, but works)
157
+ window.location.href = authorizationUrl.toString();
158
+ }
159
+ } else {
160
+ // Open in popup (default)
161
+ const width = 600;
162
+ const height = 700;
163
+ const left = window.screenX + (window.outerWidth - width) / 2;
164
+ const top = window.screenY + (window.outerHeight - height) / 2;
165
+
166
+ const popup = window.open(
167
+ authorizationUrl.toString(),
168
+ "mcp-oauth",
169
+ `width=${width},height=${height},left=${left},top=${top},popup=yes`,
170
+ );
171
+
172
+ if (!popup) {
173
+ throw new Error(
174
+ "OAuth popup was blocked. Please allow popups for this site and try again.",
175
+ );
176
+ }
177
+ }
178
+ }
179
+
180
+ saveCodeVerifier(codeVerifier: string): void {
181
+ this._codeVerifier = codeVerifier;
182
+ }
183
+
184
+ codeVerifier(): string {
185
+ if (!this._codeVerifier) {
186
+ throw new Error("Code verifier not found");
187
+ }
188
+ return this._codeVerifier;
189
+ }
190
+
191
+ invalidateCredentials(): void {
192
+ this._clientInfo = null;
193
+ this._tokens = null;
194
+ this._codeVerifier = null;
195
+ this._state = null;
196
+ }
197
+
198
+ getServerUrl(): string {
199
+ return this.serverUrl;
200
+ }
201
+
202
+ cleanup(): void {
203
+ activeOAuthSessions.delete(hashServerUrl(this.serverUrl));
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Full OAuth token info for persistence
209
+ */
210
+ export interface OAuthTokenInfo {
211
+ accessToken: string;
212
+ refreshToken: string | null;
213
+ expiresIn: number | null;
214
+ scope: string | null;
215
+ // Dynamic Client Registration info
216
+ clientId: string | null;
217
+ clientSecret: string | null;
218
+ tokenEndpoint: string | null;
219
+ }
220
+
221
+ /**
222
+ * Result from authenticateMcp
223
+ */
224
+ export interface AuthenticateMcpResult {
225
+ token: string | null;
226
+ /** Full token info for persistence (includes refresh token) */
227
+ tokenInfo: OAuthTokenInfo | null;
228
+ error: string | null;
229
+ }
230
+
231
+ /**
232
+ * Extended token result with all info needed for persistence
233
+ */
234
+ interface FullTokenResult {
235
+ tokens: OAuthTokens;
236
+ clientId: string | null;
237
+ clientSecret: string | null;
238
+ tokenEndpoint: string | null;
239
+ }
240
+
241
+ /**
242
+ * Authenticate with an MCP server using OAuth
243
+ * @param params.connectionId - The connection ID to authenticate
244
+ * @param params.meshUrl - Mesh server URL (optional, defaults to window.location.origin for same-origin apps)
245
+ * @param params.clientName - OAuth client name
246
+ * @param params.clientUri - OAuth client URI
247
+ * @param params.callbackUrl - OAuth callback URL (defaults to current origin + /oauth/callback)
248
+ * @param params.timeout - Timeout in ms (default 120000)
249
+ * @param params.scope - OAuth scopes to request
250
+ * @param params.windowMode - "popup" (default) or "tab" (for devices that block popups)
251
+ */
252
+ export async function authenticateMcp(params: {
253
+ connectionId: string;
254
+ /** Mesh server URL - optional, defaults to window.location.origin (for external apps, provide your Mesh server URL) */
255
+ meshUrl?: string;
256
+ clientName?: string;
257
+ clientUri?: string;
258
+ callbackUrl?: string;
259
+ timeout?: number;
260
+ /** OAuth scopes to request. If not provided, no scope is requested (server decides) */
261
+ scope?: string | string[];
262
+ /** Window mode: "popup" (default) or "tab" (for devices that block popups). Tab mode uses localStorage for cross-tab communication. */
263
+ windowMode?: OAuthWindowMode;
264
+ }): Promise<AuthenticateMcpResult> {
265
+ const baseUrl = params.meshUrl ?? window.location.origin;
266
+ const serverUrl = new URL(`/mcp/${params.connectionId}`, baseUrl);
267
+ const provider = new McpOAuthProvider({
268
+ serverUrl: serverUrl.href,
269
+ clientName: params.clientName,
270
+ clientUri: params.clientUri,
271
+ callbackUrl: params.callbackUrl,
272
+ scope: params.scope,
273
+ windowMode: params.windowMode,
274
+ });
275
+
276
+ try {
277
+ // Wait for OAuth callback message from popup and handle token exchange
278
+ // Uses both postMessage (primary) and localStorage (fallback for when opener is lost)
279
+ const oauthCompletePromise = new Promise<FullTokenResult>(
280
+ (resolve, reject) => {
281
+ const timeout = params.timeout || 120000;
282
+ let timeoutId: ReturnType<typeof setTimeout>;
283
+ let resolved = false;
284
+ // Use the OAuth state as the storage key - it's already unique per flow
285
+ // and will be available to the callback page via URL params
286
+ const oauthState = provider.state();
287
+ const storageKey = `${OAUTH_CALLBACK_STORAGE_KEY}${oauthState}`;
288
+
289
+ const cleanup = () => {
290
+ // Note: Race condition prevention is handled in processCallback by setting
291
+ // resolved = true immediately. This function just does the actual cleanup.
292
+ window.removeEventListener("message", handleMessage);
293
+ window.removeEventListener("storage", handleStorageEvent);
294
+ clearTimeout(timeoutId);
295
+ // Clean up storage key
296
+ try {
297
+ localStorage.removeItem(storageKey);
298
+ } catch {
299
+ // Ignore storage errors
300
+ }
301
+ };
302
+
303
+ const processCallback = async (data: {
304
+ success: boolean;
305
+ code?: string;
306
+ state?: string;
307
+ error?: string;
308
+ }) => {
309
+ // Set resolved immediately to prevent race condition with concurrent callbacks
310
+ if (resolved) return;
311
+ resolved = true;
312
+
313
+ if (!data.success) {
314
+ cleanup();
315
+ reject(new Error(data.error || "OAuth authentication failed"));
316
+ return;
317
+ }
318
+
319
+ const { code, state } = data;
320
+
321
+ if (!code) {
322
+ cleanup();
323
+ reject(new Error("Missing authorization code"));
324
+ return;
325
+ }
326
+
327
+ // Verify state matches
328
+ const storedState = provider.getStoredState();
329
+ if (storedState !== state) {
330
+ cleanup();
331
+ reject(new Error("OAuth state mismatch - possible CSRF attack"));
332
+ return;
333
+ }
334
+
335
+ try {
336
+ // Do token exchange in parent window (we have provider in memory)
337
+ const resourceMetadata =
338
+ await discoverOAuthProtectedResourceMetadata(serverUrl);
339
+ const authServerUrl =
340
+ resourceMetadata?.authorization_servers?.[0] || serverUrl;
341
+ const authServerMetadata =
342
+ await discoverAuthorizationServerMetadata(authServerUrl);
343
+
344
+ const clientInfo = provider.clientInformation();
345
+ if (!clientInfo) {
346
+ cleanup();
347
+ reject(new Error("Client information not found"));
348
+ return;
349
+ }
350
+
351
+ const codeVerifier = provider.codeVerifier();
352
+
353
+ const tokens = await exchangeAuthorization(authServerUrl, {
354
+ metadata: authServerMetadata,
355
+ clientInformation: clientInfo,
356
+ authorizationCode: code,
357
+ codeVerifier,
358
+ redirectUri: provider.redirectUrl,
359
+ resource: new URL(serverUrl),
360
+ });
361
+
362
+ cleanup();
363
+
364
+ // Resolve with full result including client info for token refresh
365
+ resolve({
366
+ tokens,
367
+ clientId: clientInfo.client_id ?? null,
368
+ clientSecret:
369
+ "client_secret" in clientInfo
370
+ ? (clientInfo.client_secret as string)
371
+ : null,
372
+ tokenEndpoint: authServerMetadata?.token_endpoint ?? null,
373
+ });
374
+ } catch (err) {
375
+ cleanup();
376
+ reject(err);
377
+ }
378
+ };
379
+
380
+ // Primary: Listen for postMessage from popup
381
+ const handleMessage = async (event: MessageEvent) => {
382
+ if (event.origin !== window.location.origin) return;
383
+ if (event.data?.type === "mcp:oauth:callback") {
384
+ await processCallback(event.data);
385
+ }
386
+ };
387
+
388
+ // Fallback: Listen for localStorage events (when window.opener is lost)
389
+ const handleStorageEvent = async (event: StorageEvent) => {
390
+ if (event.key !== storageKey || !event.newValue) return;
391
+ try {
392
+ const data = JSON.parse(event.newValue);
393
+ await processCallback(data);
394
+ } catch {
395
+ // Ignore parse errors
396
+ }
397
+ };
398
+
399
+ window.addEventListener("message", handleMessage);
400
+ window.addEventListener("storage", handleStorageEvent);
401
+
402
+ timeoutId = setTimeout(() => {
403
+ if (resolved) return;
404
+ resolved = true;
405
+ cleanup();
406
+ reject(new Error("OAuth authentication timeout"));
407
+ }, timeout);
408
+ },
409
+ );
410
+
411
+ // Start the auth flow
412
+ const result: AuthResult = await auth(provider, { serverUrl });
413
+
414
+ if (result === "REDIRECT") {
415
+ const fullResult = await oauthCompletePromise;
416
+ return {
417
+ token: fullResult.tokens.access_token,
418
+ tokenInfo: {
419
+ accessToken: fullResult.tokens.access_token,
420
+ refreshToken: fullResult.tokens.refresh_token ?? null,
421
+ expiresIn: fullResult.tokens.expires_in ?? null,
422
+ scope: fullResult.tokens.scope ?? null,
423
+ clientId: fullResult.clientId,
424
+ clientSecret: fullResult.clientSecret,
425
+ tokenEndpoint: fullResult.tokenEndpoint,
426
+ },
427
+ error: null,
428
+ };
429
+ }
430
+
431
+ // If we got here without redirect, check for tokens
432
+ const tokens = provider.tokens();
433
+ const clientInfo = provider.clientInformation();
434
+ return {
435
+ token: tokens?.access_token || null,
436
+ tokenInfo: tokens
437
+ ? {
438
+ accessToken: tokens.access_token,
439
+ refreshToken: tokens.refresh_token ?? null,
440
+ expiresIn: tokens.expires_in ?? null,
441
+ scope: tokens.scope ?? null,
442
+ clientId: clientInfo?.client_id ?? null,
443
+ clientSecret:
444
+ clientInfo && "client_secret" in clientInfo
445
+ ? (clientInfo.client_secret as string)
446
+ : null,
447
+ tokenEndpoint: null, // Would need to be passed through
448
+ }
449
+ : null,
450
+ error: null,
451
+ };
452
+ } catch (error) {
453
+ return {
454
+ token: null,
455
+ tokenInfo: null,
456
+ error: error instanceof Error ? error.message : String(error),
457
+ };
458
+ } finally {
459
+ provider.cleanup();
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Send callback data via postMessage or localStorage fallback
465
+ * @param data - The callback data to send
466
+ * @param state - The OAuth state parameter (used as localStorage key for fallback)
467
+ */
468
+ function sendCallbackData(
469
+ data: {
470
+ type: string;
471
+ success: boolean;
472
+ code?: string;
473
+ state?: string;
474
+ error?: string;
475
+ },
476
+ state: string | null,
477
+ ): boolean {
478
+ // Try postMessage first (primary method)
479
+ if (window.opener && !window.opener.closed) {
480
+ window.opener.postMessage(data, window.location.origin);
481
+ return true;
482
+ }
483
+
484
+ // Fallback: Use localStorage to communicate with parent window
485
+ // This works even when window.opener is lost due to redirects
486
+ // Use the OAuth state as the key since the parent window knows it
487
+ if (state) {
488
+ try {
489
+ const storageKey = `${OAUTH_CALLBACK_STORAGE_KEY}${state}`;
490
+ localStorage.setItem(storageKey, JSON.stringify(data));
491
+ return true;
492
+ } catch {
493
+ // Ignore storage errors
494
+ }
495
+ }
496
+
497
+ return false;
498
+ }
499
+
500
+ /**
501
+ * Handle the OAuth callback (to be called from the callback page)
502
+ *
503
+ * Forwards the authorization code to the parent window via postMessage.
504
+ * Falls back to localStorage if window.opener is not available (common with OAuth redirects).
505
+ * The parent window handles the token exchange.
506
+ */
507
+ export async function handleOAuthCallback(): Promise<{
508
+ success: boolean;
509
+ error?: string;
510
+ }> {
511
+ const params = new URLSearchParams(window.location.search);
512
+ const code = params.get("code");
513
+ let state = params.get("state");
514
+ const errorParam = params.get("error");
515
+ const errorDescription = params.get("error_description");
516
+
517
+ // Try to decode wrapped state from deco.cx first (needed for localStorage key)
518
+ let decodedState = state;
519
+ if (state) {
520
+ try {
521
+ const decoded = atob(state);
522
+ const stateObj = JSON.parse(decoded);
523
+ if (stateObj.clientState) {
524
+ decodedState = stateObj.clientState;
525
+ }
526
+ } catch {
527
+ // Use state as-is
528
+ }
529
+ }
530
+
531
+ if (errorParam) {
532
+ const errorMsg = errorDescription || errorParam;
533
+ sendCallbackData(
534
+ {
535
+ type: "mcp:oauth:callback",
536
+ success: false,
537
+ error: errorMsg,
538
+ },
539
+ decodedState,
540
+ );
541
+ return {
542
+ success: false,
543
+ error: errorMsg,
544
+ };
545
+ }
546
+
547
+ if (!code || !state) {
548
+ const error = "Missing code or state parameter";
549
+ sendCallbackData(
550
+ {
551
+ type: "mcp:oauth:callback",
552
+ success: false,
553
+ error,
554
+ },
555
+ decodedState,
556
+ );
557
+ return {
558
+ success: false,
559
+ error,
560
+ };
561
+ }
562
+
563
+ // Use the decoded state for the callback
564
+ state = decodedState || state;
565
+
566
+ // Forward code and state to parent window for token exchange
567
+ const sent = sendCallbackData(
568
+ {
569
+ type: "mcp:oauth:callback",
570
+ success: true,
571
+ code,
572
+ state,
573
+ },
574
+ state,
575
+ );
576
+
577
+ if (sent) {
578
+ return { success: true };
579
+ }
580
+
581
+ return {
582
+ success: false,
583
+ error: "Parent window not available",
584
+ };
585
+ }
586
+
587
+ /**
588
+ * Authentication status for an MCP connection
589
+ */
590
+ export interface McpAuthStatus {
591
+ /** Whether the connection is authenticated and working */
592
+ isAuthenticated: boolean;
593
+ /** Whether the server supports OAuth (has WWW-Authenticate header on 401) */
594
+ supportsOAuth: boolean;
595
+ /** Whether the current authentication is via OAuth (has stored OAuth token) */
596
+ hasOAuthToken: boolean;
597
+ /** Error message if authentication failed */
598
+ error?: string;
599
+ /** Whether this was a server error (5xx) - OAuth support is unknown in this case */
600
+ isServerError?: boolean;
601
+ }
602
+
603
+ /**
604
+ * Extract connection ID from MCP proxy URL
605
+ */
606
+ function extractConnectionIdFromUrl(url: string): string | null {
607
+ try {
608
+ const urlObj = new URL(url);
609
+ const match = urlObj.pathname.match(/^\/mcp\/([^/]+)/);
610
+ return match?.[1] ?? null;
611
+ } catch {
612
+ return null;
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Check if connection has a stored OAuth token
618
+ * @param connectionId - The connection ID to check
619
+ * @param apiBaseUrl - Base URL for the API call (optional, defaults to relative path)
620
+ */
621
+ async function checkOAuthTokenStatus(
622
+ connectionId: string,
623
+ apiBaseUrl?: string,
624
+ ): Promise<{ hasToken: boolean }> {
625
+ try {
626
+ const path = `/api/connections/${connectionId}/oauth-token/status`;
627
+ const url = apiBaseUrl ? new URL(path, apiBaseUrl).href : path;
628
+ const response = await fetch(url, {
629
+ credentials: apiBaseUrl ? "omit" : "include", // Don't send cookies for cross-origin
630
+ });
631
+ if (!response.ok) {
632
+ return { hasToken: false };
633
+ }
634
+ const data = await response.json();
635
+ return { hasToken: data.hasToken === true };
636
+ } catch {
637
+ return { hasToken: false };
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Check if an MCP connection is authenticated and whether it supports OAuth
643
+ * @param params.url - The MCP URL to check
644
+ * @param params.token - Authorization token (optional)
645
+ * @param params.meshUrl - Mesh server URL for API calls (optional, defaults to URL origin)
646
+ */
647
+ export async function isConnectionAuthenticated({
648
+ url,
649
+ token,
650
+ meshUrl,
651
+ }: {
652
+ url: string;
653
+ token: string | null;
654
+ /** Mesh server URL for API calls - optional, defaults to extracting from url parameter */
655
+ meshUrl?: string;
656
+ }): Promise<McpAuthStatus> {
657
+ try {
658
+ const headers = new Headers();
659
+ headers.set("Content-Type", "application/json");
660
+ headers.set("Accept", "application/json, text/event-stream");
661
+ if (token) {
662
+ headers.set("Authorization", `Bearer ${token}`);
663
+ }
664
+
665
+ const response = await fetch(url, {
666
+ method: "POST",
667
+ headers,
668
+ body: JSON.stringify({
669
+ jsonrpc: "2.0",
670
+ id: 1,
671
+ method: "initialize",
672
+ params: {
673
+ protocolVersion: "2025-06-18",
674
+ capabilities: {},
675
+ clientInfo: {
676
+ name: "@decocms/mesh MCP client",
677
+ version: "1.0.0",
678
+ },
679
+ },
680
+ }),
681
+ });
682
+
683
+ // Extract connection ID for OAuth token status check
684
+ const connectionId = extractConnectionIdFromUrl(url);
685
+ // Determine base URL for API calls (meshUrl > URL origin > window.location.origin)
686
+ const apiBaseUrl = meshUrl ?? new URL(url).origin;
687
+
688
+ if (response.ok) {
689
+ // Check if we have an OAuth token stored for this connection
690
+ const oauthStatus = connectionId
691
+ ? await checkOAuthTokenStatus(connectionId, apiBaseUrl)
692
+ : { hasToken: false };
693
+
694
+ return {
695
+ isAuthenticated: true,
696
+ // When authenticated, we can't determine OAuth support from the response
697
+ // (no 401 to check WWW-Authenticate header). Default to false.
698
+ supportsOAuth: false,
699
+ hasOAuthToken: oauthStatus.hasToken,
700
+ };
701
+ }
702
+
703
+ // Try to get error message from response body
704
+ let error: string | undefined;
705
+ try {
706
+ const body = await response.json();
707
+ error = body.error || body.message;
708
+ } catch {
709
+ // Ignore JSON parse errors
710
+ }
711
+
712
+ // Handle 5xx server errors separately - we can't determine OAuth support
713
+ if (response.status >= 500) {
714
+ return {
715
+ isAuthenticated: false,
716
+ supportsOAuth: false,
717
+ hasOAuthToken: false,
718
+ error: error || `HTTP ${response.status}`,
719
+ isServerError: true,
720
+ };
721
+ }
722
+
723
+ // For 401/403, check if server supports OAuth by looking for WWW-Authenticate header
724
+ const wwwAuth = response.headers.get("WWW-Authenticate");
725
+ const supportsOAuth = !!wwwAuth;
726
+
727
+ return {
728
+ isAuthenticated: false,
729
+ supportsOAuth,
730
+ hasOAuthToken: false,
731
+ error: error || `HTTP ${response.status}`,
732
+ };
733
+ } catch (error) {
734
+ console.error("[isConnectionAuthenticated] Error:", error);
735
+ return {
736
+ isAuthenticated: false,
737
+ supportsOAuth: false,
738
+ hasOAuthToken: false,
739
+ error: (error as Error).message,
740
+ };
741
+ }
742
+ }