• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thoughtspot / mcp-server / 15125403956

19 May 2025 11:38PM CUT coverage: 12.262% (-0.1%) from 12.363%
15125403956

Pull #14

github

web-flow
Merge ab8f5de83 into 96f630b8b
Pull Request #14: Make base64 encoding url safe

5 of 101 branches covered (4.95%)

Branch coverage included in aggregate %.

0 of 6 new or added lines in 1 file covered. (0.0%)

40 of 266 relevant lines covered (15.04%)

0.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/oauth-manager/oauth-utils.ts
1
import type { ClientInfo, AuthRequest } from '@cloudflare/workers-oauth-provider'
2

3

4
/**
5
 * Configuration for the approval dialog
6
 */
7
export interface ApprovalDialogOptions {
8
    /**
9
     * Client information to display in the approval dialog
10
     */
11
    client: ClientInfo | null
12
    /**
13
     * Server information to display in the approval dialog
14
     */
15
    server: {
16
      name: string
17
      logo?: string
18
      description?: string
19
    }
20
    /**
21
     * Arbitrary state data to pass through the approval flow
22
     * Will be encoded in the form and returned when approval is complete
23
     */
24
    state: Record<string, any>
25
    /**
26
     * Name of the cookie to use for storing approvals
27
     * @default "mcp_approved_clients"
28
     */
29
    cookieName?: string
30
    /**
31
     * Secret used to sign cookies for verification
32
     * Can be a string or Uint8Array
33
     * @default Built-in Uint8Array key
34
     */
35
    cookieSecret?: string | Uint8Array
36
    /**
37
     * Cookie domain
38
     * @default current domain
39
     */
40
    cookieDomain?: string
41
    /**
42
     * Cookie path
43
     * @default "/"
44
     */
45
    cookiePath?: string
46
    /**
47
     * Cookie max age in seconds
48
     * @default 30 days
49
     */
50
    cookieMaxAge?: number
51
}
52

53
/**
54
 * Renders an approval dialog for OAuth authorization
55
 * The dialog displays information about the client and server
56
 * and includes a form to submit approval
57
 *
58
 * @param request - The HTTP request
59
 * @param options - Configuration for the approval dialog
60
 * @returns A Response containing the HTML approval dialog
61
 */
62
export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response {
63
    const { client, server, state } = options
×
64
  
65
    // Encode state for form submission
66
    const encodedState = btoa(JSON.stringify(state))
×
67
  
68
    // Sanitize any untrusted content
69
    const serverName = sanitizeHtml(server.name)
×
70
    const clientName = client?.clientName ? sanitizeHtml(client.clientName) : 'Unknown MCP Client'
×
71
    const serverDescription = server.description ? sanitizeHtml(server.description) : ''
×
72
  
73
    // Safe URLs
74
    const logoUrl = server.logo ? sanitizeHtml(server.logo) : ''
×
75
    const clientUri = client?.clientUri ? sanitizeHtml(client.clientUri) : ''
×
76
    const policyUri = client?.policyUri ? sanitizeHtml(client.policyUri) : ''
×
77
    const tosUri = client?.tosUri ? sanitizeHtml(client.tosUri) : ''
×
78
  
79
    // Client contacts
80
    const contacts = client?.contacts && client.contacts.length > 0 ? sanitizeHtml(client.contacts.join(', ')) : ''
×
81
  
82
    // Get redirect URIs
83
    const redirectUris = client?.redirectUris && client.redirectUris.length > 0 ? client.redirectUris.map((uri) => sanitizeHtml(uri)) : []
×
84
  
85
    // Generate HTML for the approval dialog
86
    const htmlContent = `
×
87
      <!DOCTYPE html>
88
      <html lang="en">
89
        <head>
90
          <meta charset="UTF-8">
91
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
92
          <title>${clientName} | Authorization Request</title>
93
          <style>
94
            /* Modern, formal styling with system fonts */
95
            :root {
96
              --primary-color: #1a56db;
97
              --primary-hover: #1e429f;
98
              --error-color: #dc2626;
99
              --border-color: #e5e7eb;
100
              --text-color: #111827;
101
              --text-secondary: #4b5563;
102
              --background-color: #fff;
103
              --card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
104
              --input-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
105
            }
106
            
107
            body {
108
              font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 
109
                          Helvetica, Arial, sans-serif, "Apple Color Emoji", 
110
                          "Segoe UI Emoji", "Segoe UI Symbol";
111
              line-height: 1.6;
112
              color: var(--text-color);
113
              background-color: #f3f4f6;
114
              margin: 0;
115
              padding: 0;
116
              min-height: 100vh;
117
              display: flex;
118
              align-items: center;
119
              justify-content: center;
120
            }
121
            
122
            .container {
123
              max-width: 640px;
124
              width: 100%;
125
              margin: 2rem;
126
              padding: 0;
127
            }
128
            
129
            .precard {
130
              padding: 2.5rem 2rem;
131
              text-align: center;
132
              background: linear-gradient(to bottom, #ffffff, #f9fafb);
133
              border-radius: 12px 12px 0 0;
134
              border: 1px solid var(--border-color);
135
              border-bottom: none;
136
            }
137
            
138
            .card {
139
              background-color: var(--background-color);
140
              border-radius: 0 0 12px 12px;
141
              box-shadow: var(--card-shadow);
142
              padding: 2.5rem;
143
              border: 1px solid var(--border-color);
144
              border-top: none;
145
            }
146
            
147
            .header {
148
              display: flex;
149
              align-items: center;
150
              justify-content: center;
151
              margin-bottom: 1.5rem;
152
            }
153
            
154
            .logo {
155
              width: 56px;
156
              height: 56px;
157
              margin-right: 1rem;
158
              border-radius: 12px;
159
              object-fit: contain;
160
              box-shadow: var(--card-shadow);
161
            }
162
            
163
            .title {
164
              margin: 0;
165
              font-size: 1.5rem;
166
              font-weight: 600;
167
              color: var(--text-color);
168
              letter-spacing: -0.025em;
169
            }
170
            
171
            .alert {
172
              margin: 0;
173
              font-size: 1.75rem;
174
              font-weight: 600;
175
              margin: 1.5rem 0;
176
              text-align: center;
177
              color: var(--text-color);
178
              letter-spacing: -0.025em;
179
            }
180
            
181
            .description {
182
              color: var(--text-secondary);
183
              font-size: 1.125rem;
184
              max-width: 32rem;
185
              margin: 0 auto;
186
            }
187
            
188
            .form-section {
189
              margin-top: 2.5rem;
190
              padding-top: 2rem;
191
              border-top: 1px solid var(--border-color);
192
            }
193
            
194
            .client-info {
195
              border: 1px solid var(--border-color);
196
              border-radius: 8px;
197
              padding: 1.5rem;
198
              margin-bottom: 2rem;
199
              background-color: #f9fafb;
200
            }
201
            
202
            .client-name {
203
              font-weight: 600;
204
              font-size: 1.25rem;
205
              margin: 0 0 1rem 0;
206
              color: var(--text-color);
207
            }
208
            
209
            .client-detail {
210
              display: flex;
211
              margin-bottom: 0.75rem;
212
              align-items: baseline;
213
            }
214
            
215
            .detail-label {
216
              font-weight: 500;
217
              min-width: 140px;
218
              color: var(--text-secondary);
219
            }
220
            
221
            .detail-value {
222
              font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
223
              word-break: break-all;
224
              color: var(--text-color);
225
            }
226
            
227
            .detail-value a {
228
              color: var(--primary-color);
229
              text-decoration: none;
230
              transition: color 0.2s;
231
            }
232
            
233
            .detail-value a:hover {
234
              color: var(--primary-hover);
235
              text-decoration: underline;
236
            }
237
            
238
            .detail-value.small {
239
              font-size: 0.875em;
240
            }
241
            
242
            .actions {
243
              display: flex;
244
              justify-content: flex-end;
245
              gap: 1rem;
246
              margin-top: 2.5rem;
247
            }
248
            
249
            .button {
250
              padding: 0.875rem 1.75rem;
251
              border-radius: 8px;
252
              font-weight: 500;
253
              cursor: pointer;
254
              border: none;
255
              font-size: 1rem;
256
              transition: all 0.2s;
257
            }
258
            
259
            .button-primary {
260
              background-color: var(--primary-color);
261
              color: white;
262
            }
263
            
264
            .button-primary:hover {
265
              background-color: var(--primary-hover);
266
              transform: translateY(-1px);
267
            }
268
            
269
            .button-secondary {
270
              background-color: white;
271
              border: 1px solid var(--border-color);
272
              color: var(--text-color);
273
            }
274
            
275
            .button-secondary:hover {
276
              background-color: #f9fafb;
277
              border-color: #d1d5db;
278
            }
279

280
            .form-group {
281
              margin-bottom: 2rem;
282
            }
283

284
            .form-group label {
285
              display: block;
286
              margin-bottom: 0.75rem;
287
              font-weight: 500;
288
              color: var(--text-color);
289
              font-size: 1.125rem;
290
            }
291

292
            .form-group input {
293
              width: 100%;
294
              padding: 0.875rem 1rem;
295
              border: 1px solid var(--border-color);
296
              border-radius: 8px;
297
              font-size: 1rem;
298
              transition: all 0.2s;
299
              background-color: white;
300
              box-shadow: var(--input-shadow);
301
            }
302

303
            .form-group input:focus {
304
              outline: none;
305
              border-color: var(--primary-color);
306
              box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.1);
307
            }
308

309
            .form-group input::placeholder {
310
              color: #9ca3af;
311
            }
312
            
313
            /* Responsive adjustments */
314
            @media (max-width: 640px) {
315
              .container {
316
                margin: 1rem;
317
              }
318
              
319
              .precard {
320
                padding: 2rem 1.5rem;
321
              }
322
              
323
              .card {
324
                padding: 1.5rem;
325
              }
326
              
327
              .client-detail {
328
                flex-direction: column;
329
              }
330
              
331
              .detail-label {
332
                min-width: unset;
333
                margin-bottom: 0.25rem;
334
              }
335
              
336
              .actions {
337
                flex-direction: column;
338
              }
339
              
340
              .button {
341
                width: 100%;
342
              }
343

344
              .alert {
345
                font-size: 1.5rem;
346
              }
347

348
              .description {
349
                font-size: 1rem;
350
              }
351
            }
352
          </style>
353
        </head>
354
        <body>
355
          <div class="container">
356
            <div class="precard">
357
              <div class="header">
358
                ${logoUrl ? `<img src="${logoUrl}" alt="${serverName} Logo" class="logo">` : ''}
×
359
                <h1 class="title">${serverName}</h1>
360
              </div>
361
              
362
              ${serverDescription ? `<p class="description">${serverDescription}</p>` : ''}
×
363
            </div>
364
              
365
            <div class="card">
366
              <h2 class="alert">Authorization Request</h2>
367
              
368
              <div class="client-info">
369
                <div class="client-detail">
370
                  <div class="detail-label">Client Name:</div>
371
                  <div class="detail-value">
372
                    ${clientName}
373
                  </div>
374
                </div>
375
                
376
                ${
377
                  clientUri
×
378
                    ? `
379
                  <div class="client-detail">
380
                    <div class="detail-label">Website:</div>
381
                    <div class="detail-value small">
382
                      <a href="${clientUri}" target="_blank" rel="noopener noreferrer">
383
                        ${clientUri}
384
                      </a>
385
                    </div>
386
                  </div>
387
                `
388
                    : ''
389
                }
390
                
391
                ${
392
                  policyUri
×
393
                    ? `
394
                  <div class="client-detail">
395
                    <div class="detail-label">Privacy Policy:</div>
396
                    <div class="detail-value">
397
                      <a href="${policyUri}" target="_blank" rel="noopener noreferrer">
398
                        ${policyUri}
399
                      </a>
400
                    </div>
401
                  </div>
402
                `
403
                    : ''
404
                }
405
                
406
                ${
407
                  tosUri
×
408
                    ? `
409
                  <div class="client-detail">
410
                    <div class="detail-label">Terms of Service:</div>
411
                    <div class="detail-value">
412
                      <a href="${tosUri}" target="_blank" rel="noopener noreferrer">
413
                        ${tosUri}
414
                      </a>
415
                    </div>
416
                  </div>
417
                `
418
                    : ''
419
                }
420
                
421
                ${
422
                  redirectUris.length > 0
×
423
                    ? `
424
                  <div class="client-detail">
425
                    <div class="detail-label">Redirect URIs:</div>
426
                    <div class="detail-value small">
427
                      ${redirectUris.map((uri) => `<div>${uri}</div>`).join('')}
×
428
                    </div>
429
                  </div>
430
                `
431
                    : ''
432
                }
433
                
434
                ${
435
                  contacts
×
436
                    ? `
437
                  <div class="client-detail">
438
                    <div class="detail-label">Contact:</div>
439
                    <div class="detail-value">${contacts}</div>
440
                  </div>
441
                `
442
                    : ''
443
                }
444
              </div>
445
              
446
              <p class="description">Please provide your ThoughtSpot instance URL to authorize this client.</p>
447
              
448
              <div class="form-section">
449
                <form method="post" action="${new URL(request.url).pathname}">
450
                  <input type="hidden" name="state" value="${encodedState}">
451
                  
452
                  <div class="form-group">
453
                    <label for="instanceUrl">ThoughtSpot Instance URL</label>
454
                    <input type="text" id="instanceUrl" name="instanceUrl" required 
455
                           placeholder="https://your-instance.thoughtspot.cloud">
456
                  </div>
457
                  
458
                  <div class="actions">
459
                    <button type="button" class="button button-secondary" onclick="window.history.back()">Cancel</button>
460
                    <button type="submit" class="button button-primary">Authorize Access</button>
461
                  </div>
462
                </form>
463
              </div>
464
            </div>
465
          </div>
466
        </body>
467
      </html>
468
    `
469
  
470
    return new Response(htmlContent, {
×
471
      headers: {
472
        'Content-Type': 'text/html; charset=utf-8',
473
      },
474
    })
475
}
476

477
/**
478
 * Decodes a base64-encoded state string back into an object
479
 */
480
function decodeState<T>(encodedState: string): T {
481
    try {
×
482
        const decoded = atob(encodedState);
×
483
        return JSON.parse(decoded) as T;
×
484
    } catch (e) {
485
        console.error('Error decoding state:', e);
×
486
        throw new Error('Invalid state format');
×
487
    }
488
}
489

490
/**
491
 * Result of parsing the approval form submission.
492
 */
493
export interface ParsedApprovalResult {
494
    /** The original state object passed through the form. */
495
    state: any
496
    /** The instance URL extracted from the form. */
497
    instanceUrl: string
498
  }
499
  
500

501
/**
502
 * Validates and sanitizes a URL to ensure it's a valid ThoughtSpot instance URL
503
 * @param url - The URL to validate and sanitize
504
 * @returns The sanitized URL
505
 * @throws Error if the URL is invalid
506
 */
507
function validateAndSanitizeUrl(url: string): string {
508
    try {
×
509
        // Remove any whitespace
510
        const trimmedUrl = url.trim();
×
511
        
512
        // Add https:// if no protocol is specified
513
        const urlWithProtocol = trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://') 
×
514
            ? trimmedUrl 
515
            : `https://${trimmedUrl}`;
516
        
517
        const parsedUrl = new URL(urlWithProtocol);
×
518
        
519
        // Remove trailing slashes and normalize the URL
520
        const sanitizedUrl = parsedUrl.origin;
×
521
        
522
        return sanitizedUrl;
×
523
    } catch (e) {
524
        if (e instanceof Error) {
×
525
            throw new Error(`Invalid URL: ${e.message}`);
×
526
        }
527
        throw new Error('Invalid URL format');
×
528
    }
529
}
530

531
/**
532
 * Parses the form submission from the approval dialog, extracts the state,
533
 * and generates Set-Cookie headers to mark the client as approved.
534
 *
535
 * @param request - The incoming POST Request object containing the form data.
536
 * @returns A promise resolving to an object containing the parsed state and necessary headers.
537
 * @throws If the request method is not POST, form data is invalid, or state is missing.
538
 */
539
export async function parseRedirectApproval(request: Request): Promise<ParsedApprovalResult> {
540
    if (request.method !== 'POST') {
×
541
        throw new Error('Invalid request method. Expected POST.')
×
542
    }
543
  
544
    let state: any
545
    let clientId: string | undefined
546
    let instanceUrl: string | undefined
547
    try {
×
548
        const formData = await request.formData()
×
549
        const encodedState = formData.get('state')
×
550
        const rawInstanceUrl = formData.get('instanceUrl') as string;
×
551
  
552
        if (typeof encodedState !== 'string' || !encodedState) {
×
553
            throw new Error("Missing or invalid 'state' in form data.")
×
554
        }
555
  
556
        state = decodeState<{ oauthReqInfo?: AuthRequest }>(encodedState)
×
557
        clientId = state?.oauthReqInfo?.clientId
×
558
  
559
        if (!clientId) {
×
560
            throw new Error('Could not extract clientId from state object.')
×
561
        }
562

563
        if (!rawInstanceUrl) {
×
564
            throw new Error('Missing instance URL')
×
565
        }
566

567
        // Validate and sanitize the instance URL
568
        instanceUrl = validateAndSanitizeUrl(rawInstanceUrl);
×
569
    } catch (e) {
570
        console.error('Error processing form submission:', e)
×
571
        throw new Error(`Failed to parse approval form: ${e instanceof Error ? e.message : String(e)}`)
×
572
    }
573
  
574
    return { state, instanceUrl }
×
575
}
576

577
/**
578
 * Sanitizes HTML content to prevent XSS attacks
579
 * @param unsafe - The unsafe string that might contain HTML
580
 * @returns A safe string with HTML special characters escaped
581
 */
582
function sanitizeHtml(unsafe: string): string {
583
    return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
×
584
  }
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc