fastmcp 3.23.0 → 3.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.
@@ -0,0 +1,1844 @@
1
+ // src/auth/OAuthProxy.ts
2
+ import { randomBytes as randomBytes4 } from "crypto";
3
+
4
+ // src/auth/utils/claimsExtractor.ts
5
+ var ClaimsExtractor = class {
6
+ config;
7
+ // Claims that MUST NOT be copied from upstream (protect proxy's JWT integrity)
8
+ PROTECTED_CLAIMS = /* @__PURE__ */ new Set([
9
+ "aud",
10
+ "client_id",
11
+ "exp",
12
+ "iat",
13
+ "iss",
14
+ "jti",
15
+ "nbf"
16
+ ]);
17
+ constructor(config) {
18
+ if (typeof config === "boolean") {
19
+ config = config ? {} : { fromAccessToken: false, fromIdToken: false };
20
+ }
21
+ this.config = {
22
+ allowComplexClaims: config.allowComplexClaims || false,
23
+ allowedClaims: config.allowedClaims,
24
+ blockedClaims: config.blockedClaims || [],
25
+ claimPrefix: config.claimPrefix !== void 0 ? config.claimPrefix : false,
26
+ // Default: no prefix
27
+ fromAccessToken: config.fromAccessToken !== false,
28
+ // Default: true
29
+ fromIdToken: config.fromIdToken !== false,
30
+ // Default: true
31
+ maxClaimValueSize: config.maxClaimValueSize || 2e3
32
+ };
33
+ }
34
+ /**
35
+ * Extract claims from a token (access token or ID token)
36
+ */
37
+ async extract(token, tokenType) {
38
+ if (tokenType === "access" && !this.config.fromAccessToken) {
39
+ return null;
40
+ }
41
+ if (tokenType === "id" && !this.config.fromIdToken) {
42
+ return null;
43
+ }
44
+ if (!this.isJWT(token)) {
45
+ return null;
46
+ }
47
+ const payload = this.decodeJWTPayload(token);
48
+ if (!payload) {
49
+ return null;
50
+ }
51
+ const filtered = this.filterClaims(payload);
52
+ return this.applyPrefix(filtered);
53
+ }
54
+ /**
55
+ * Apply prefix to claim names (if configured)
56
+ */
57
+ applyPrefix(claims) {
58
+ const prefix = this.config.claimPrefix;
59
+ if (prefix === false || prefix === "" || prefix === void 0) {
60
+ return claims;
61
+ }
62
+ const result = {};
63
+ for (const [key, value] of Object.entries(claims)) {
64
+ result[`${prefix}${key}`] = value;
65
+ }
66
+ return result;
67
+ }
68
+ /**
69
+ * Decode JWT payload without signature verification
70
+ * Safe because token came from trusted upstream via server-to-server exchange
71
+ */
72
+ decodeJWTPayload(token) {
73
+ try {
74
+ const parts = token.split(".");
75
+ if (parts.length !== 3) {
76
+ return null;
77
+ }
78
+ const payload = Buffer.from(parts[1], "base64url").toString("utf-8");
79
+ return JSON.parse(payload);
80
+ } catch (error) {
81
+ console.warn(`Failed to decode JWT payload: ${error}`);
82
+ return null;
83
+ }
84
+ }
85
+ /**
86
+ * Filter claims based on security rules
87
+ */
88
+ filterClaims(claims) {
89
+ const result = {};
90
+ for (const [key, value] of Object.entries(claims)) {
91
+ if (this.PROTECTED_CLAIMS.has(key)) {
92
+ continue;
93
+ }
94
+ if (this.config.blockedClaims?.includes(key)) {
95
+ continue;
96
+ }
97
+ if (this.config.allowedClaims && !this.config.allowedClaims.includes(key)) {
98
+ continue;
99
+ }
100
+ if (!this.isValidClaimValue(value)) {
101
+ console.warn(`Skipping claim '${key}' due to invalid value`);
102
+ continue;
103
+ }
104
+ result[key] = value;
105
+ }
106
+ return result;
107
+ }
108
+ /**
109
+ * Check if a token is in JWT format
110
+ */
111
+ isJWT(token) {
112
+ return token.split(".").length === 3;
113
+ }
114
+ /**
115
+ * Validate a claim value (type and size checks)
116
+ */
117
+ isValidClaimValue(value) {
118
+ if (value === null || value === void 0) {
119
+ return false;
120
+ }
121
+ const type = typeof value;
122
+ if (type === "string") {
123
+ const maxSize = this.config.maxClaimValueSize ?? 2e3;
124
+ return value.length <= maxSize;
125
+ }
126
+ if (type === "number" || type === "boolean") {
127
+ return true;
128
+ }
129
+ if (Array.isArray(value) || type === "object") {
130
+ if (!this.config.allowComplexClaims) {
131
+ return false;
132
+ }
133
+ try {
134
+ const stringified = JSON.stringify(value);
135
+ const maxSize = this.config.maxClaimValueSize ?? 2e3;
136
+ return stringified.length <= maxSize;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+ };
144
+
145
+ // src/auth/utils/consent.ts
146
+ import { createHmac } from "crypto";
147
+ var ConsentManager = class {
148
+ signingKey;
149
+ constructor(signingKey) {
150
+ this.signingKey = signingKey || this.generateDefaultKey();
151
+ }
152
+ /**
153
+ * Create HTTP response with consent screen
154
+ */
155
+ createConsentResponse(transaction, provider) {
156
+ const consentData = {
157
+ clientName: "MCP Client",
158
+ provider,
159
+ scope: transaction.scope,
160
+ timestamp: Date.now(),
161
+ transactionId: transaction.id
162
+ };
163
+ const html = this.generateConsentScreen(consentData);
164
+ return new Response(html, {
165
+ headers: {
166
+ "Content-Type": "text/html; charset=utf-8"
167
+ },
168
+ status: 200
169
+ });
170
+ }
171
+ /**
172
+ * Generate HTML for consent screen
173
+ */
174
+ generateConsentScreen(data) {
175
+ const { clientName, provider, scope, transactionId } = data;
176
+ return `
177
+ <!DOCTYPE html>
178
+ <html lang="en">
179
+ <head>
180
+ <meta charset="UTF-8">
181
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
182
+ <title>Authorization Request</title>
183
+ <style>
184
+ * {
185
+ margin: 0;
186
+ padding: 0;
187
+ box-sizing: border-box;
188
+ }
189
+
190
+ body {
191
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
192
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
193
+ min-height: 100vh;
194
+ display: flex;
195
+ justify-content: center;
196
+ align-items: center;
197
+ padding: 20px;
198
+ }
199
+
200
+ .consent-container {
201
+ background: white;
202
+ border-radius: 12px;
203
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
204
+ max-width: 480px;
205
+ width: 100%;
206
+ padding: 40px;
207
+ }
208
+
209
+ .header {
210
+ text-align: center;
211
+ margin-bottom: 30px;
212
+ }
213
+
214
+ .header h1 {
215
+ color: #1a202c;
216
+ font-size: 24px;
217
+ margin-bottom: 8px;
218
+ }
219
+
220
+ .header p {
221
+ color: #718096;
222
+ font-size: 14px;
223
+ }
224
+
225
+ .app-info {
226
+ background: #f7fafc;
227
+ border-radius: 8px;
228
+ padding: 20px;
229
+ margin-bottom: 24px;
230
+ }
231
+
232
+ .app-info h2 {
233
+ color: #2d3748;
234
+ font-size: 18px;
235
+ margin-bottom: 12px;
236
+ }
237
+
238
+ .app-name {
239
+ color: #667eea;
240
+ font-weight: 600;
241
+ }
242
+
243
+ .permissions {
244
+ margin-top: 16px;
245
+ }
246
+
247
+ .permissions h3 {
248
+ color: #4a5568;
249
+ font-size: 14px;
250
+ margin-bottom: 8px;
251
+ font-weight: 600;
252
+ }
253
+
254
+ .permissions ul {
255
+ list-style: none;
256
+ }
257
+
258
+ .permissions li {
259
+ color: #718096;
260
+ font-size: 14px;
261
+ padding: 6px 0;
262
+ padding-left: 24px;
263
+ position: relative;
264
+ }
265
+
266
+ .permissions li:before {
267
+ content: "\u2713";
268
+ position: absolute;
269
+ left: 0;
270
+ color: #48bb78;
271
+ font-weight: bold;
272
+ }
273
+
274
+ .warning {
275
+ background: #fffaf0;
276
+ border-left: 4px solid #ed8936;
277
+ padding: 12px 16px;
278
+ margin-bottom: 24px;
279
+ border-radius: 4px;
280
+ }
281
+
282
+ .warning p {
283
+ color: #744210;
284
+ font-size: 13px;
285
+ line-height: 1.5;
286
+ }
287
+
288
+ .actions {
289
+ display: flex;
290
+ gap: 12px;
291
+ }
292
+
293
+ button {
294
+ flex: 1;
295
+ padding: 14px 24px;
296
+ border: none;
297
+ border-radius: 6px;
298
+ font-size: 16px;
299
+ font-weight: 600;
300
+ cursor: pointer;
301
+ transition: all 0.2s;
302
+ }
303
+
304
+ .approve {
305
+ background: #667eea;
306
+ color: white;
307
+ }
308
+
309
+ .approve:hover {
310
+ background: #5a67d8;
311
+ transform: translateY(-1px);
312
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
313
+ }
314
+
315
+ .deny {
316
+ background: #e2e8f0;
317
+ color: #4a5568;
318
+ }
319
+
320
+ .deny:hover {
321
+ background: #cbd5e0;
322
+ }
323
+
324
+ .footer {
325
+ margin-top: 24px;
326
+ text-align: center;
327
+ color: #a0aec0;
328
+ font-size: 12px;
329
+ }
330
+ </style>
331
+ </head>
332
+ <body>
333
+ <div class="consent-container">
334
+ <div class="header">
335
+ <h1>\u{1F510} Authorization Request</h1>
336
+ <p>via ${this.escapeHtml(provider)}</p>
337
+ </div>
338
+
339
+ <div class="app-info">
340
+ <h2>
341
+ <span class="app-name">${this.escapeHtml(clientName || "An application")}</span>
342
+ requests access
343
+ </h2>
344
+
345
+ <div class="permissions">
346
+ <h3>This will allow the app to:</h3>
347
+ <ul>
348
+ ${scope.map((s) => `<li>${this.escapeHtml(this.formatScope(s))}</li>`).join("")}
349
+ </ul>
350
+ </div>
351
+ </div>
352
+
353
+ <div class="warning">
354
+ <p>
355
+ <strong>\u26A0\uFE0F Important:</strong> Only approve if you trust this application.
356
+ By approving, you authorize it to access your account information.
357
+ </p>
358
+ </div>
359
+
360
+ <form method="POST" action="/oauth/consent">
361
+ <input type="hidden" name="transaction_id" value="${this.escapeHtml(transactionId)}">
362
+ <div class="actions">
363
+ <button type="submit" name="action" value="deny" class="deny">
364
+ Deny
365
+ </button>
366
+ <button type="submit" name="action" value="approve" class="approve">
367
+ Approve
368
+ </button>
369
+ </div>
370
+ </form>
371
+
372
+ <div class="footer">
373
+ <p>This consent is required to prevent unauthorized access.</p>
374
+ </div>
375
+ </div>
376
+ </body>
377
+ </html>
378
+ `.trim();
379
+ }
380
+ /**
381
+ * Sign consent data for cookie
382
+ */
383
+ signConsentCookie(data) {
384
+ const payload = JSON.stringify(data);
385
+ const signature = this.sign(payload);
386
+ return `${Buffer.from(payload).toString("base64")}.${signature}`;
387
+ }
388
+ /**
389
+ * Validate and parse consent cookie
390
+ */
391
+ validateConsentCookie(cookie) {
392
+ try {
393
+ const [payloadB64, signature] = cookie.split(".");
394
+ if (!payloadB64 || !signature) {
395
+ return null;
396
+ }
397
+ const payload = Buffer.from(payloadB64, "base64").toString("utf8");
398
+ const expectedSignature = this.sign(payload);
399
+ if (signature !== expectedSignature) {
400
+ return null;
401
+ }
402
+ const data = JSON.parse(payload);
403
+ const age = Date.now() - data.timestamp;
404
+ if (age > 5 * 60 * 1e3) {
405
+ return null;
406
+ }
407
+ return data;
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+ /**
413
+ * Escape HTML to prevent XSS
414
+ */
415
+ escapeHtml(text) {
416
+ const map = {
417
+ "'": "&#x27;",
418
+ '"': "&quot;",
419
+ "/": "&#x2F;",
420
+ "&": "&amp;",
421
+ "<": "&lt;",
422
+ ">": "&gt;"
423
+ };
424
+ return text.replace(/[&<>"'/]/g, (char) => map[char] || char);
425
+ }
426
+ /**
427
+ * Format scope for display
428
+ */
429
+ formatScope(scope) {
430
+ const scopeMap = {
431
+ email: "Access your email address",
432
+ openid: "Verify your identity",
433
+ profile: "View your basic profile information",
434
+ "read:user": "Read your user information",
435
+ "write:user": "Modify your user information"
436
+ };
437
+ return scopeMap[scope] || scope.replace(/_/g, " ").replace(/:/g, " - ");
438
+ }
439
+ /**
440
+ * Generate default signing key if none provided
441
+ */
442
+ generateDefaultKey() {
443
+ return `fastmcp-consent-${Date.now()}-${Math.random()}`;
444
+ }
445
+ /**
446
+ * Sign a payload using HMAC-SHA256
447
+ */
448
+ sign(payload) {
449
+ return createHmac("sha256", this.signingKey).update(payload).digest("hex");
450
+ }
451
+ };
452
+
453
+ // src/auth/utils/jwtIssuer.ts
454
+ import { createHmac as createHmac2, pbkdf2, randomBytes } from "crypto";
455
+ import { promisify } from "util";
456
+ var pbkdf2Async = promisify(pbkdf2);
457
+ var JWTIssuer = class {
458
+ accessTokenTtl;
459
+ audience;
460
+ issuer;
461
+ refreshTokenTtl;
462
+ signingKey;
463
+ constructor(config) {
464
+ this.issuer = config.issuer;
465
+ this.audience = config.audience;
466
+ this.accessTokenTtl = config.accessTokenTtl || 3600;
467
+ this.refreshTokenTtl = config.refreshTokenTtl || 2592e3;
468
+ this.signingKey = Buffer.from(config.signingKey);
469
+ }
470
+ /**
471
+ * Derive a signing key from a secret
472
+ * Uses PBKDF2 for key derivation
473
+ */
474
+ static async deriveKey(secret, iterations = 1e5) {
475
+ const salt = Buffer.from("fastmcp-oauth-proxy");
476
+ const key = await pbkdf2Async(secret, salt, iterations, 32, "sha256");
477
+ return key.toString("base64");
478
+ }
479
+ /**
480
+ * Issue an access token
481
+ */
482
+ issueAccessToken(clientId, scope, additionalClaims) {
483
+ const now = Math.floor(Date.now() / 1e3);
484
+ const jti = this.generateJti();
485
+ const claims = {
486
+ aud: this.audience,
487
+ client_id: clientId,
488
+ exp: now + this.accessTokenTtl,
489
+ iat: now,
490
+ iss: this.issuer,
491
+ jti,
492
+ scope,
493
+ // Merge additional claims (custom claims from upstream)
494
+ ...additionalClaims || {}
495
+ };
496
+ return this.signToken(claims);
497
+ }
498
+ /**
499
+ * Issue a refresh token
500
+ */
501
+ issueRefreshToken(clientId, scope, additionalClaims) {
502
+ const now = Math.floor(Date.now() / 1e3);
503
+ const jti = this.generateJti();
504
+ const claims = {
505
+ aud: this.audience,
506
+ client_id: clientId,
507
+ exp: now + this.refreshTokenTtl,
508
+ iat: now,
509
+ iss: this.issuer,
510
+ jti,
511
+ scope,
512
+ // Merge additional claims (custom claims from upstream)
513
+ ...additionalClaims || {}
514
+ };
515
+ return this.signToken(claims);
516
+ }
517
+ /**
518
+ * Validate a JWT token
519
+ */
520
+ async verify(token) {
521
+ try {
522
+ const parts = token.split(".");
523
+ if (parts.length !== 3) {
524
+ return {
525
+ error: "Invalid token format",
526
+ valid: false
527
+ };
528
+ }
529
+ const [headerB64, payloadB64, signatureB64] = parts;
530
+ const expectedSignature = this.sign(`${headerB64}.${payloadB64}`);
531
+ if (signatureB64 !== expectedSignature) {
532
+ return {
533
+ error: "Invalid signature",
534
+ valid: false
535
+ };
536
+ }
537
+ const claims = JSON.parse(
538
+ Buffer.from(payloadB64, "base64url").toString("utf-8")
539
+ );
540
+ const now = Math.floor(Date.now() / 1e3);
541
+ if (claims.exp <= now) {
542
+ return {
543
+ claims,
544
+ error: "Token expired",
545
+ valid: false
546
+ };
547
+ }
548
+ if (claims.iss !== this.issuer) {
549
+ return {
550
+ claims,
551
+ error: "Invalid issuer",
552
+ valid: false
553
+ };
554
+ }
555
+ if (claims.aud !== this.audience) {
556
+ return {
557
+ claims,
558
+ error: "Invalid audience",
559
+ valid: false
560
+ };
561
+ }
562
+ return {
563
+ claims,
564
+ valid: true
565
+ };
566
+ } catch (error) {
567
+ return {
568
+ error: error instanceof Error ? error.message : "Validation failed",
569
+ valid: false
570
+ };
571
+ }
572
+ }
573
+ /**
574
+ * Generate unique JWT ID
575
+ */
576
+ generateJti() {
577
+ return randomBytes(16).toString("base64url");
578
+ }
579
+ /**
580
+ * Sign data with HMAC-SHA256
581
+ */
582
+ sign(data) {
583
+ const hmac = createHmac2("sha256", this.signingKey);
584
+ hmac.update(data);
585
+ return hmac.digest("base64url");
586
+ }
587
+ /**
588
+ * Sign a JWT token
589
+ */
590
+ signToken(claims) {
591
+ const header = {
592
+ alg: "HS256",
593
+ typ: "JWT"
594
+ };
595
+ const headerB64 = Buffer.from(JSON.stringify(header)).toString("base64url");
596
+ const payloadB64 = Buffer.from(JSON.stringify(claims)).toString(
597
+ "base64url"
598
+ );
599
+ const signature = this.sign(`${headerB64}.${payloadB64}`);
600
+ return `${headerB64}.${payloadB64}.${signature}`;
601
+ }
602
+ };
603
+
604
+ // src/auth/utils/pkce.ts
605
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
606
+ var PKCEUtils = class _PKCEUtils {
607
+ /**
608
+ * Generate a code challenge from a verifier
609
+ * @param verifier The code verifier
610
+ * @param method Challenge method: 'S256' or 'plain' (default: 'S256')
611
+ * @returns Base64URL-encoded challenge string
612
+ */
613
+ static generateChallenge(verifier, method = "S256") {
614
+ if (method === "plain") {
615
+ return verifier;
616
+ }
617
+ if (method === "S256") {
618
+ const hash = createHash("sha256");
619
+ hash.update(verifier);
620
+ return _PKCEUtils.base64URLEncode(hash.digest());
621
+ }
622
+ throw new Error(`Unsupported challenge method: ${method}`);
623
+ }
624
+ /**
625
+ * Generate a complete PKCE pair (verifier + challenge)
626
+ * @param method Challenge method: 'S256' or 'plain' (default: 'S256')
627
+ * @returns Object containing verifier and challenge
628
+ */
629
+ static generatePair(method = "S256") {
630
+ const verifier = _PKCEUtils.generateVerifier();
631
+ const challenge = _PKCEUtils.generateChallenge(verifier, method);
632
+ return {
633
+ challenge,
634
+ verifier
635
+ };
636
+ }
637
+ /**
638
+ * Generate a cryptographically secure code verifier
639
+ * @param length Length of verifier (43-128 characters, default: 128)
640
+ * @returns Base64URL-encoded verifier string
641
+ */
642
+ static generateVerifier(length = 128) {
643
+ if (length < 43 || length > 128) {
644
+ throw new Error("PKCE verifier length must be between 43 and 128");
645
+ }
646
+ const byteLength = Math.ceil(length * 3 / 4);
647
+ const randomBytesBuffer = randomBytes2(byteLength);
648
+ return _PKCEUtils.base64URLEncode(randomBytesBuffer).slice(0, length);
649
+ }
650
+ /**
651
+ * Validate a code verifier against a challenge
652
+ * @param verifier The code verifier to validate
653
+ * @param challenge The expected challenge
654
+ * @param method The challenge method used
655
+ * @returns True if verifier matches challenge
656
+ */
657
+ static validateChallenge(verifier, challenge, method) {
658
+ if (!verifier || !challenge) {
659
+ return false;
660
+ }
661
+ if (method === "plain") {
662
+ return verifier === challenge;
663
+ }
664
+ if (method === "S256") {
665
+ const computedChallenge = _PKCEUtils.generateChallenge(verifier, "S256");
666
+ return computedChallenge === challenge;
667
+ }
668
+ return false;
669
+ }
670
+ /**
671
+ * Encode a buffer as base64url (RFC 4648)
672
+ * @param buffer Buffer to encode
673
+ * @returns Base64URL-encoded string
674
+ */
675
+ static base64URLEncode(buffer) {
676
+ return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
677
+ }
678
+ };
679
+
680
+ // src/auth/utils/tokenStore.ts
681
+ import {
682
+ createCipheriv,
683
+ createDecipheriv,
684
+ randomBytes as randomBytes3,
685
+ scryptSync
686
+ } from "crypto";
687
+ var EncryptedTokenStorage = class {
688
+ algorithm = "aes-256-gcm";
689
+ backend;
690
+ encryptionKey;
691
+ constructor(backend, encryptionKey) {
692
+ this.backend = backend;
693
+ const salt = Buffer.from("fastmcp-oauth-proxy-salt");
694
+ this.encryptionKey = scryptSync(encryptionKey, salt, 32);
695
+ }
696
+ async cleanup() {
697
+ await this.backend.cleanup();
698
+ }
699
+ async delete(key) {
700
+ await this.backend.delete(key);
701
+ }
702
+ async get(key) {
703
+ const encrypted = await this.backend.get(key);
704
+ if (!encrypted) {
705
+ return null;
706
+ }
707
+ try {
708
+ const decrypted = await this.decrypt(
709
+ encrypted,
710
+ this.encryptionKey
711
+ );
712
+ return JSON.parse(decrypted);
713
+ } catch (error) {
714
+ console.error("Failed to decrypt value:", error);
715
+ return null;
716
+ }
717
+ }
718
+ async save(key, value, ttl) {
719
+ const encrypted = await this.encrypt(
720
+ JSON.stringify(value),
721
+ this.encryptionKey
722
+ );
723
+ await this.backend.save(key, encrypted, ttl);
724
+ }
725
+ async decrypt(ciphertext, key) {
726
+ const parts = ciphertext.split(":");
727
+ if (parts.length !== 3) {
728
+ throw new Error("Invalid encrypted data format");
729
+ }
730
+ const [ivHex, authTagHex, encrypted] = parts;
731
+ const iv = Buffer.from(ivHex, "hex");
732
+ const authTag = Buffer.from(authTagHex, "hex");
733
+ const decipher = createDecipheriv(this.algorithm, key, iv);
734
+ decipher.setAuthTag(
735
+ authTag
736
+ );
737
+ let decrypted = decipher.update(encrypted, "hex", "utf8");
738
+ decrypted += decipher.final("utf8");
739
+ return decrypted;
740
+ }
741
+ async encrypt(plaintext, key) {
742
+ const iv = randomBytes3(16);
743
+ const cipher = createCipheriv(this.algorithm, key, iv);
744
+ let encrypted = cipher.update(plaintext, "utf8", "hex");
745
+ encrypted += cipher.final("hex");
746
+ const authTag = cipher.getAuthTag();
747
+ return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
748
+ }
749
+ };
750
+ var MemoryTokenStorage = class {
751
+ cleanupInterval = null;
752
+ store = /* @__PURE__ */ new Map();
753
+ constructor(cleanupIntervalMs = 6e4) {
754
+ this.cleanupInterval = setInterval(
755
+ () => void this.cleanup(),
756
+ cleanupIntervalMs
757
+ );
758
+ }
759
+ async cleanup() {
760
+ const now = Date.now();
761
+ const keysToDelete = [];
762
+ for (const [key, entry] of this.store.entries()) {
763
+ if (entry.expiresAt < now) {
764
+ keysToDelete.push(key);
765
+ }
766
+ }
767
+ for (const key of keysToDelete) {
768
+ this.store.delete(key);
769
+ }
770
+ }
771
+ async delete(key) {
772
+ this.store.delete(key);
773
+ }
774
+ /**
775
+ * Destroy the storage and clear cleanup interval
776
+ */
777
+ destroy() {
778
+ if (this.cleanupInterval) {
779
+ clearInterval(this.cleanupInterval);
780
+ this.cleanupInterval = null;
781
+ }
782
+ this.store.clear();
783
+ }
784
+ async get(key) {
785
+ const entry = this.store.get(key);
786
+ if (!entry) {
787
+ return null;
788
+ }
789
+ if (entry.expiresAt < Date.now()) {
790
+ this.store.delete(key);
791
+ return null;
792
+ }
793
+ return entry.value;
794
+ }
795
+ async save(key, value, ttl) {
796
+ const expiresAt = ttl ? Date.now() + ttl * 1e3 : Number.MAX_SAFE_INTEGER;
797
+ this.store.set(key, {
798
+ expiresAt,
799
+ value
800
+ });
801
+ }
802
+ /**
803
+ * Get the number of stored items
804
+ */
805
+ size() {
806
+ return this.store.size;
807
+ }
808
+ };
809
+
810
+ // src/auth/OAuthProxy.ts
811
+ var OAuthProxy = class {
812
+ claimsExtractor = null;
813
+ cleanupInterval = null;
814
+ clientCodes = /* @__PURE__ */ new Map();
815
+ config;
816
+ consentManager;
817
+ jwtIssuer;
818
+ registeredClients = /* @__PURE__ */ new Map();
819
+ tokenStorage;
820
+ transactions = /* @__PURE__ */ new Map();
821
+ constructor(config) {
822
+ this.config = {
823
+ allowedRedirectUriPatterns: ["https://*", "http://localhost:*"],
824
+ authorizationCodeTtl: 300,
825
+ // 5 minutes
826
+ consentRequired: true,
827
+ enableTokenSwap: true,
828
+ // Enabled by default for security
829
+ redirectPath: "/oauth/callback",
830
+ transactionTtl: 600,
831
+ // 10 minutes
832
+ ...config
833
+ };
834
+ let storage = config.tokenStorage || new MemoryTokenStorage();
835
+ const isAlreadyEncrypted = storage.constructor.name === "EncryptedTokenStorage";
836
+ if (!isAlreadyEncrypted && config.encryptionKey !== false) {
837
+ const encryptionKey = typeof config.encryptionKey === "string" ? config.encryptionKey : this.generateSigningKey();
838
+ storage = new EncryptedTokenStorage(storage, encryptionKey);
839
+ }
840
+ this.tokenStorage = storage;
841
+ this.consentManager = new ConsentManager(
842
+ config.consentSigningKey || this.generateSigningKey()
843
+ );
844
+ if (this.config.enableTokenSwap) {
845
+ const signingKey = this.config.jwtSigningKey || this.generateSigningKey();
846
+ this.jwtIssuer = new JWTIssuer({
847
+ audience: this.config.baseUrl,
848
+ issuer: this.config.baseUrl,
849
+ signingKey
850
+ });
851
+ }
852
+ const claimsConfig = config.customClaimsPassthrough !== void 0 ? config.customClaimsPassthrough : true;
853
+ if (claimsConfig !== false) {
854
+ this.claimsExtractor = new ClaimsExtractor(claimsConfig);
855
+ }
856
+ this.startCleanup();
857
+ }
858
+ /**
859
+ * OAuth authorization endpoint
860
+ */
861
+ async authorize(params) {
862
+ if (!params.client_id || !params.redirect_uri || !params.response_type) {
863
+ throw new OAuthProxyError(
864
+ "invalid_request",
865
+ "Missing required parameters"
866
+ );
867
+ }
868
+ if (params.response_type !== "code") {
869
+ throw new OAuthProxyError(
870
+ "unsupported_response_type",
871
+ "Only 'code' response type is supported"
872
+ );
873
+ }
874
+ if (params.code_challenge && !params.code_challenge_method) {
875
+ throw new OAuthProxyError(
876
+ "invalid_request",
877
+ "code_challenge_method required when code_challenge is present"
878
+ );
879
+ }
880
+ const transaction = await this.createTransaction(params);
881
+ if (this.config.consentRequired && !transaction.consentGiven) {
882
+ return this.consentManager.createConsentResponse(
883
+ transaction,
884
+ this.getProviderName()
885
+ );
886
+ }
887
+ return this.redirectToUpstream(transaction);
888
+ }
889
+ /**
890
+ * Stop cleanup interval and destroy resources
891
+ */
892
+ destroy() {
893
+ if (this.cleanupInterval) {
894
+ clearInterval(this.cleanupInterval);
895
+ this.cleanupInterval = null;
896
+ }
897
+ this.transactions.clear();
898
+ this.clientCodes.clear();
899
+ this.registeredClients.clear();
900
+ }
901
+ /**
902
+ * Token endpoint - exchange authorization code for tokens
903
+ */
904
+ async exchangeAuthorizationCode(request) {
905
+ if (request.grant_type !== "authorization_code") {
906
+ throw new OAuthProxyError(
907
+ "unsupported_grant_type",
908
+ "Only authorization_code grant type is supported"
909
+ );
910
+ }
911
+ const clientCode = this.clientCodes.get(request.code);
912
+ if (!clientCode) {
913
+ throw new OAuthProxyError(
914
+ "invalid_grant",
915
+ "Invalid or expired authorization code"
916
+ );
917
+ }
918
+ if (clientCode.clientId !== request.client_id) {
919
+ throw new OAuthProxyError("invalid_client", "Client ID mismatch");
920
+ }
921
+ if (clientCode.codeChallenge) {
922
+ if (!request.code_verifier) {
923
+ throw new OAuthProxyError(
924
+ "invalid_request",
925
+ "code_verifier required for PKCE"
926
+ );
927
+ }
928
+ const valid = PKCEUtils.validateChallenge(
929
+ request.code_verifier,
930
+ clientCode.codeChallenge,
931
+ clientCode.codeChallengeMethod
932
+ );
933
+ if (!valid) {
934
+ throw new OAuthProxyError("invalid_grant", "Invalid PKCE verifier");
935
+ }
936
+ }
937
+ if (clientCode.used) {
938
+ throw new OAuthProxyError(
939
+ "invalid_grant",
940
+ "Authorization code already used"
941
+ );
942
+ }
943
+ clientCode.used = true;
944
+ this.clientCodes.set(request.code, clientCode);
945
+ if (this.config.enableTokenSwap && this.jwtIssuer) {
946
+ return await this.issueSwappedTokens(
947
+ clientCode.clientId,
948
+ clientCode.upstreamTokens
949
+ );
950
+ } else {
951
+ const response = {
952
+ access_token: clientCode.upstreamTokens.accessToken,
953
+ expires_in: clientCode.upstreamTokens.expiresIn,
954
+ token_type: clientCode.upstreamTokens.tokenType
955
+ };
956
+ if (clientCode.upstreamTokens.refreshToken) {
957
+ response.refresh_token = clientCode.upstreamTokens.refreshToken;
958
+ }
959
+ if (clientCode.upstreamTokens.idToken) {
960
+ response.id_token = clientCode.upstreamTokens.idToken;
961
+ }
962
+ if (clientCode.upstreamTokens.scope.length > 0) {
963
+ response.scope = clientCode.upstreamTokens.scope.join(" ");
964
+ }
965
+ return response;
966
+ }
967
+ }
968
+ /**
969
+ * Token endpoint - refresh access token
970
+ */
971
+ async exchangeRefreshToken(request) {
972
+ if (request.grant_type !== "refresh_token") {
973
+ throw new OAuthProxyError(
974
+ "unsupported_grant_type",
975
+ "Only refresh_token grant type is supported"
976
+ );
977
+ }
978
+ const tokenResponse = await fetch(this.config.upstreamTokenEndpoint, {
979
+ body: new URLSearchParams({
980
+ client_id: this.config.upstreamClientId,
981
+ client_secret: this.config.upstreamClientSecret,
982
+ grant_type: "refresh_token",
983
+ refresh_token: request.refresh_token,
984
+ ...request.scope && { scope: request.scope }
985
+ }),
986
+ headers: {
987
+ "Content-Type": "application/x-www-form-urlencoded"
988
+ },
989
+ method: "POST"
990
+ });
991
+ if (!tokenResponse.ok) {
992
+ const error = await tokenResponse.json();
993
+ throw new OAuthProxyError(
994
+ error.error || "invalid_grant",
995
+ error.error_description
996
+ );
997
+ }
998
+ const tokens = await tokenResponse.json();
999
+ return {
1000
+ access_token: tokens.access_token,
1001
+ expires_in: tokens.expires_in,
1002
+ id_token: tokens.id_token,
1003
+ refresh_token: tokens.refresh_token,
1004
+ scope: tokens.scope,
1005
+ token_type: tokens.token_type || "Bearer"
1006
+ };
1007
+ }
1008
+ /**
1009
+ * Get OAuth discovery metadata
1010
+ */
1011
+ getAuthorizationServerMetadata() {
1012
+ return {
1013
+ authorizationEndpoint: `${this.config.baseUrl}/oauth/authorize`,
1014
+ codeChallengeMethodsSupported: ["S256", "plain"],
1015
+ grantTypesSupported: ["authorization_code", "refresh_token"],
1016
+ issuer: this.config.baseUrl,
1017
+ registrationEndpoint: `${this.config.baseUrl}/oauth/register`,
1018
+ responseTypesSupported: ["code"],
1019
+ scopesSupported: this.config.scopes || [],
1020
+ tokenEndpoint: `${this.config.baseUrl}/oauth/token`,
1021
+ tokenEndpointAuthMethodsSupported: [
1022
+ "client_secret_basic",
1023
+ "client_secret_post"
1024
+ ]
1025
+ };
1026
+ }
1027
+ /**
1028
+ * Handle OAuth callback from upstream provider
1029
+ */
1030
+ async handleCallback(request) {
1031
+ const url = new URL(request.url);
1032
+ const code = url.searchParams.get("code");
1033
+ const state = url.searchParams.get("state");
1034
+ const error = url.searchParams.get("error");
1035
+ if (error) {
1036
+ const errorDescription = url.searchParams.get("error_description");
1037
+ throw new OAuthProxyError(error, errorDescription || void 0);
1038
+ }
1039
+ if (!code || !state) {
1040
+ throw new OAuthProxyError(
1041
+ "invalid_request",
1042
+ "Missing code or state parameter"
1043
+ );
1044
+ }
1045
+ const transaction = this.transactions.get(state);
1046
+ if (!transaction) {
1047
+ throw new OAuthProxyError("invalid_request", "Invalid or expired state");
1048
+ }
1049
+ const upstreamTokens = await this.exchangeUpstreamCode(code, transaction);
1050
+ const clientCode = this.generateAuthorizationCode(
1051
+ transaction,
1052
+ upstreamTokens
1053
+ );
1054
+ this.transactions.delete(state);
1055
+ const redirectUrl = new URL(transaction.clientCallbackUrl);
1056
+ redirectUrl.searchParams.set("code", clientCode);
1057
+ redirectUrl.searchParams.set("state", transaction.state);
1058
+ return new Response(null, {
1059
+ headers: {
1060
+ Location: redirectUrl.toString()
1061
+ },
1062
+ status: 302
1063
+ });
1064
+ }
1065
+ /**
1066
+ * Handle consent form submission
1067
+ */
1068
+ async handleConsent(request) {
1069
+ const formData = await request.formData();
1070
+ const transactionId = formData.get("transaction_id");
1071
+ const action = formData.get("action");
1072
+ if (!transactionId) {
1073
+ throw new OAuthProxyError("invalid_request", "Missing transaction_id");
1074
+ }
1075
+ const transaction = this.transactions.get(transactionId);
1076
+ if (!transaction) {
1077
+ throw new OAuthProxyError(
1078
+ "invalid_request",
1079
+ "Invalid or expired transaction"
1080
+ );
1081
+ }
1082
+ if (action === "deny") {
1083
+ this.transactions.delete(transactionId);
1084
+ const redirectUrl = new URL(transaction.clientCallbackUrl);
1085
+ redirectUrl.searchParams.set("error", "access_denied");
1086
+ redirectUrl.searchParams.set(
1087
+ "error_description",
1088
+ "User denied authorization"
1089
+ );
1090
+ redirectUrl.searchParams.set("state", transaction.state);
1091
+ return new Response(null, {
1092
+ headers: {
1093
+ Location: redirectUrl.toString()
1094
+ },
1095
+ status: 302
1096
+ });
1097
+ }
1098
+ transaction.consentGiven = true;
1099
+ this.transactions.set(transactionId, transaction);
1100
+ return this.redirectToUpstream(transaction);
1101
+ }
1102
+ /**
1103
+ * Load upstream tokens from a FastMCP JWT
1104
+ */
1105
+ async loadUpstreamTokens(fastmcpToken) {
1106
+ if (!this.jwtIssuer) {
1107
+ return null;
1108
+ }
1109
+ const result = await this.jwtIssuer.verify(fastmcpToken);
1110
+ if (!result.valid || !result.claims?.jti) {
1111
+ return null;
1112
+ }
1113
+ const mapping = await this.tokenStorage.get(
1114
+ `mapping:${result.claims.jti}`
1115
+ );
1116
+ if (!mapping) {
1117
+ return null;
1118
+ }
1119
+ const upstreamTokens = await this.tokenStorage.get(
1120
+ `upstream:${mapping.upstreamTokenKey}`
1121
+ );
1122
+ return upstreamTokens;
1123
+ }
1124
+ /**
1125
+ * RFC 7591 Dynamic Client Registration
1126
+ */
1127
+ async registerClient(request) {
1128
+ if (!request.redirect_uris || request.redirect_uris.length === 0) {
1129
+ throw new OAuthProxyError(
1130
+ "invalid_client_metadata",
1131
+ "redirect_uris is required"
1132
+ );
1133
+ }
1134
+ for (const uri of request.redirect_uris) {
1135
+ if (!this.validateRedirectUri(uri)) {
1136
+ throw new OAuthProxyError(
1137
+ "invalid_redirect_uri",
1138
+ `Invalid redirect URI: ${uri}`
1139
+ );
1140
+ }
1141
+ }
1142
+ const clientId = this.config.upstreamClientId;
1143
+ const client = {
1144
+ callbackUrl: request.redirect_uris[0],
1145
+ clientId,
1146
+ clientSecret: this.config.upstreamClientSecret,
1147
+ metadata: {
1148
+ client_name: request.client_name,
1149
+ client_uri: request.client_uri,
1150
+ contacts: request.contacts,
1151
+ jwks: request.jwks,
1152
+ jwks_uri: request.jwks_uri,
1153
+ logo_uri: request.logo_uri,
1154
+ policy_uri: request.policy_uri,
1155
+ scope: request.scope,
1156
+ software_id: request.software_id,
1157
+ software_version: request.software_version,
1158
+ tos_uri: request.tos_uri
1159
+ },
1160
+ registeredAt: /* @__PURE__ */ new Date()
1161
+ };
1162
+ this.registeredClients.set(request.redirect_uris[0], client);
1163
+ const response = {
1164
+ client_id: clientId,
1165
+ client_id_issued_at: Math.floor(Date.now() / 1e3),
1166
+ // Echo back optional metadata
1167
+ client_name: request.client_name,
1168
+ client_secret: this.config.upstreamClientSecret,
1169
+ client_secret_expires_at: 0,
1170
+ // Never expires
1171
+ client_uri: request.client_uri,
1172
+ contacts: request.contacts,
1173
+ grant_types: request.grant_types || [
1174
+ "authorization_code",
1175
+ "refresh_token"
1176
+ ],
1177
+ jwks: request.jwks,
1178
+ jwks_uri: request.jwks_uri,
1179
+ logo_uri: request.logo_uri,
1180
+ policy_uri: request.policy_uri,
1181
+ redirect_uris: request.redirect_uris,
1182
+ response_types: request.response_types || ["code"],
1183
+ scope: request.scope,
1184
+ software_id: request.software_id,
1185
+ software_version: request.software_version,
1186
+ token_endpoint_auth_method: request.token_endpoint_auth_method || "client_secret_basic",
1187
+ tos_uri: request.tos_uri
1188
+ };
1189
+ return response;
1190
+ }
1191
+ /**
1192
+ * Clean up expired transactions and codes
1193
+ */
1194
+ cleanup() {
1195
+ const now = Date.now();
1196
+ for (const [id, transaction] of this.transactions.entries()) {
1197
+ if (transaction.expiresAt.getTime() < now) {
1198
+ this.transactions.delete(id);
1199
+ }
1200
+ }
1201
+ for (const [code, clientCode] of this.clientCodes.entries()) {
1202
+ if (clientCode.expiresAt.getTime() < now) {
1203
+ this.clientCodes.delete(code);
1204
+ }
1205
+ }
1206
+ void this.tokenStorage.cleanup();
1207
+ }
1208
+ /**
1209
+ * Create a new OAuth transaction
1210
+ */
1211
+ async createTransaction(params) {
1212
+ const transactionId = this.generateId();
1213
+ const proxyPkce = PKCEUtils.generatePair("S256");
1214
+ const transaction = {
1215
+ clientCallbackUrl: params.redirect_uri,
1216
+ clientCodeChallenge: params.code_challenge || "",
1217
+ clientCodeChallengeMethod: params.code_challenge_method || "plain",
1218
+ clientId: params.client_id,
1219
+ createdAt: /* @__PURE__ */ new Date(),
1220
+ expiresAt: new Date(
1221
+ Date.now() + (this.config.transactionTtl || 600) * 1e3
1222
+ ),
1223
+ id: transactionId,
1224
+ proxyCodeChallenge: proxyPkce.challenge,
1225
+ proxyCodeVerifier: proxyPkce.verifier,
1226
+ scope: params.scope ? params.scope.split(" ") : this.config.scopes || [],
1227
+ state: params.state || this.generateId()
1228
+ };
1229
+ this.transactions.set(transactionId, transaction);
1230
+ return transaction;
1231
+ }
1232
+ /**
1233
+ * Exchange authorization code with upstream provider
1234
+ */
1235
+ async exchangeUpstreamCode(code, transaction) {
1236
+ const tokenResponse = await fetch(this.config.upstreamTokenEndpoint, {
1237
+ body: new URLSearchParams({
1238
+ client_id: this.config.upstreamClientId,
1239
+ client_secret: this.config.upstreamClientSecret,
1240
+ code,
1241
+ code_verifier: transaction.proxyCodeVerifier,
1242
+ grant_type: "authorization_code",
1243
+ redirect_uri: `${this.config.baseUrl}${this.config.redirectPath}`
1244
+ }),
1245
+ headers: {
1246
+ "Content-Type": "application/x-www-form-urlencoded"
1247
+ },
1248
+ method: "POST"
1249
+ });
1250
+ if (!tokenResponse.ok) {
1251
+ const error = await tokenResponse.json();
1252
+ throw new OAuthProxyError(
1253
+ error.error || "server_error",
1254
+ error.error_description
1255
+ );
1256
+ }
1257
+ const tokens = await tokenResponse.json();
1258
+ return {
1259
+ accessToken: tokens.access_token,
1260
+ expiresIn: tokens.expires_in || 3600,
1261
+ idToken: tokens.id_token,
1262
+ issuedAt: /* @__PURE__ */ new Date(),
1263
+ refreshToken: tokens.refresh_token,
1264
+ scope: tokens.scope ? tokens.scope.split(" ") : transaction.scope,
1265
+ tokenType: tokens.token_type || "Bearer"
1266
+ };
1267
+ }
1268
+ /**
1269
+ * Extract JTI from a JWT token
1270
+ */
1271
+ async extractJti(token) {
1272
+ if (!this.jwtIssuer) {
1273
+ throw new Error("JWT issuer not initialized");
1274
+ }
1275
+ const result = await this.jwtIssuer.verify(token);
1276
+ if (!result.valid || !result.claims?.jti) {
1277
+ throw new Error("Failed to extract JTI from token");
1278
+ }
1279
+ return result.claims.jti;
1280
+ }
1281
+ /**
1282
+ * Extract custom claims from upstream tokens
1283
+ * Combines claims from access token and ID token (if present)
1284
+ */
1285
+ async extractUpstreamClaims(upstreamTokens) {
1286
+ if (!this.claimsExtractor) {
1287
+ return null;
1288
+ }
1289
+ const allClaims = {};
1290
+ const accessClaims = await this.claimsExtractor.extract(
1291
+ upstreamTokens.accessToken,
1292
+ "access"
1293
+ );
1294
+ if (accessClaims) {
1295
+ Object.assign(allClaims, accessClaims);
1296
+ }
1297
+ if (upstreamTokens.idToken) {
1298
+ const idClaims = await this.claimsExtractor.extract(
1299
+ upstreamTokens.idToken,
1300
+ "id"
1301
+ );
1302
+ if (idClaims) {
1303
+ for (const [key, value] of Object.entries(idClaims)) {
1304
+ if (!(key in allClaims)) {
1305
+ allClaims[key] = value;
1306
+ }
1307
+ }
1308
+ }
1309
+ }
1310
+ return Object.keys(allClaims).length > 0 ? allClaims : null;
1311
+ }
1312
+ /**
1313
+ * Generate authorization code for client
1314
+ */
1315
+ generateAuthorizationCode(transaction, upstreamTokens) {
1316
+ const code = this.generateId();
1317
+ const clientCode = {
1318
+ clientId: transaction.clientId,
1319
+ code,
1320
+ codeChallenge: transaction.clientCodeChallenge,
1321
+ codeChallengeMethod: transaction.clientCodeChallengeMethod,
1322
+ createdAt: /* @__PURE__ */ new Date(),
1323
+ expiresAt: new Date(
1324
+ Date.now() + (this.config.authorizationCodeTtl || 300) * 1e3
1325
+ ),
1326
+ transactionId: transaction.id,
1327
+ upstreamTokens
1328
+ };
1329
+ this.clientCodes.set(code, clientCode);
1330
+ return code;
1331
+ }
1332
+ /**
1333
+ * Generate secure random ID
1334
+ */
1335
+ generateId() {
1336
+ return randomBytes4(32).toString("base64url");
1337
+ }
1338
+ /**
1339
+ * Generate signing key for consent cookies
1340
+ */
1341
+ generateSigningKey() {
1342
+ return randomBytes4(32).toString("hex");
1343
+ }
1344
+ /**
1345
+ * Get provider name for display
1346
+ */
1347
+ getProviderName() {
1348
+ const url = new URL(this.config.upstreamAuthorizationEndpoint);
1349
+ return url.hostname;
1350
+ }
1351
+ /**
1352
+ * Issue swapped tokens (JWT pattern)
1353
+ * Issues short-lived FastMCP JWTs and stores upstream tokens securely
1354
+ */
1355
+ async issueSwappedTokens(clientId, upstreamTokens) {
1356
+ if (!this.jwtIssuer) {
1357
+ throw new Error("JWT issuer not initialized");
1358
+ }
1359
+ const customClaims = await this.extractUpstreamClaims(upstreamTokens);
1360
+ const upstreamTokenKey = this.generateId();
1361
+ await this.tokenStorage.save(
1362
+ `upstream:${upstreamTokenKey}`,
1363
+ upstreamTokens,
1364
+ upstreamTokens.expiresIn
1365
+ );
1366
+ const accessToken = this.jwtIssuer.issueAccessToken(
1367
+ clientId,
1368
+ upstreamTokens.scope,
1369
+ customClaims || void 0
1370
+ );
1371
+ const accessJti = await this.extractJti(accessToken);
1372
+ await this.tokenStorage.save(
1373
+ `mapping:${accessJti}`,
1374
+ {
1375
+ clientId,
1376
+ createdAt: /* @__PURE__ */ new Date(),
1377
+ expiresAt: new Date(Date.now() + upstreamTokens.expiresIn * 1e3),
1378
+ jti: accessJti,
1379
+ scope: upstreamTokens.scope,
1380
+ upstreamTokenKey
1381
+ },
1382
+ upstreamTokens.expiresIn
1383
+ );
1384
+ const response = {
1385
+ access_token: accessToken,
1386
+ expires_in: 3600,
1387
+ // FastMCP JWT expiration (1 hour)
1388
+ scope: upstreamTokens.scope.join(" "),
1389
+ token_type: "Bearer"
1390
+ };
1391
+ if (upstreamTokens.refreshToken) {
1392
+ const refreshToken = this.jwtIssuer.issueRefreshToken(
1393
+ clientId,
1394
+ upstreamTokens.scope,
1395
+ customClaims || void 0
1396
+ );
1397
+ const refreshJti = await this.extractJti(refreshToken);
1398
+ await this.tokenStorage.save(
1399
+ `mapping:${refreshJti}`,
1400
+ {
1401
+ clientId,
1402
+ createdAt: /* @__PURE__ */ new Date(),
1403
+ expiresAt: new Date(Date.now() + 2592e3 * 1e3),
1404
+ // 30 days
1405
+ jti: refreshJti,
1406
+ scope: upstreamTokens.scope,
1407
+ upstreamTokenKey
1408
+ },
1409
+ 2592e3
1410
+ // 30 days
1411
+ );
1412
+ response.refresh_token = refreshToken;
1413
+ }
1414
+ return response;
1415
+ }
1416
+ /**
1417
+ * Match URI against pattern (supports wildcards)
1418
+ */
1419
+ matchesPattern(uri, pattern) {
1420
+ const regex = new RegExp(
1421
+ "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
1422
+ );
1423
+ return regex.test(uri);
1424
+ }
1425
+ /**
1426
+ * Redirect to upstream OAuth provider
1427
+ */
1428
+ redirectToUpstream(transaction) {
1429
+ const authUrl = new URL(this.config.upstreamAuthorizationEndpoint);
1430
+ authUrl.searchParams.set("client_id", this.config.upstreamClientId);
1431
+ authUrl.searchParams.set(
1432
+ "redirect_uri",
1433
+ `${this.config.baseUrl}${this.config.redirectPath}`
1434
+ );
1435
+ authUrl.searchParams.set("response_type", "code");
1436
+ authUrl.searchParams.set("state", transaction.id);
1437
+ if (transaction.scope.length > 0) {
1438
+ authUrl.searchParams.set("scope", transaction.scope.join(" "));
1439
+ }
1440
+ if (!this.config.forwardPkce) {
1441
+ authUrl.searchParams.set(
1442
+ "code_challenge",
1443
+ transaction.proxyCodeChallenge
1444
+ );
1445
+ authUrl.searchParams.set("code_challenge_method", "S256");
1446
+ }
1447
+ return new Response(null, {
1448
+ headers: {
1449
+ Location: authUrl.toString()
1450
+ },
1451
+ status: 302
1452
+ });
1453
+ }
1454
+ /**
1455
+ * Start periodic cleanup of expired transactions and codes
1456
+ */
1457
+ startCleanup() {
1458
+ this.cleanupInterval = setInterval(() => {
1459
+ this.cleanup();
1460
+ }, 6e4);
1461
+ }
1462
+ /**
1463
+ * Validate redirect URI against allowed patterns
1464
+ */
1465
+ validateRedirectUri(uri) {
1466
+ try {
1467
+ const url = new URL(uri);
1468
+ const patterns = this.config.allowedRedirectUriPatterns || [];
1469
+ for (const pattern of patterns) {
1470
+ if (this.matchesPattern(uri, pattern)) {
1471
+ return true;
1472
+ }
1473
+ }
1474
+ return url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1";
1475
+ } catch {
1476
+ return false;
1477
+ }
1478
+ }
1479
+ };
1480
+ var OAuthProxyError = class extends Error {
1481
+ constructor(code, description, statusCode = 400) {
1482
+ super(code);
1483
+ this.code = code;
1484
+ this.description = description;
1485
+ this.statusCode = statusCode;
1486
+ this.name = "OAuthProxyError";
1487
+ }
1488
+ toJSON() {
1489
+ return {
1490
+ error: this.code,
1491
+ error_description: this.description
1492
+ };
1493
+ }
1494
+ toResponse() {
1495
+ return new Response(JSON.stringify(this.toJSON()), {
1496
+ headers: { "Content-Type": "application/json" },
1497
+ status: this.statusCode
1498
+ });
1499
+ }
1500
+ };
1501
+
1502
+ // src/auth/providers/AzureProvider.ts
1503
+ var AzureProvider = class extends OAuthProxy {
1504
+ constructor(config) {
1505
+ const tenantId = config.tenantId || "common";
1506
+ super({
1507
+ baseUrl: config.baseUrl,
1508
+ consentRequired: config.consentRequired,
1509
+ scopes: config.scopes || ["openid", "profile", "email"],
1510
+ upstreamAuthorizationEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`,
1511
+ upstreamClientId: config.clientId,
1512
+ upstreamClientSecret: config.clientSecret,
1513
+ upstreamTokenEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`
1514
+ });
1515
+ }
1516
+ };
1517
+
1518
+ // src/auth/providers/GitHubProvider.ts
1519
+ var GitHubProvider = class extends OAuthProxy {
1520
+ constructor(config) {
1521
+ super({
1522
+ baseUrl: config.baseUrl,
1523
+ consentRequired: config.consentRequired,
1524
+ scopes: config.scopes || ["read:user", "user:email"],
1525
+ upstreamAuthorizationEndpoint: "https://github.com/login/oauth/authorize",
1526
+ upstreamClientId: config.clientId,
1527
+ upstreamClientSecret: config.clientSecret,
1528
+ upstreamTokenEndpoint: "https://github.com/login/oauth/access_token"
1529
+ });
1530
+ }
1531
+ };
1532
+
1533
+ // src/auth/providers/GoogleProvider.ts
1534
+ var GoogleProvider = class extends OAuthProxy {
1535
+ constructor(config) {
1536
+ super({
1537
+ baseUrl: config.baseUrl,
1538
+ consentRequired: config.consentRequired,
1539
+ scopes: config.scopes || ["openid", "profile", "email"],
1540
+ upstreamAuthorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
1541
+ upstreamClientId: config.clientId,
1542
+ upstreamClientSecret: config.clientSecret,
1543
+ upstreamTokenEndpoint: "https://oauth2.googleapis.com/token"
1544
+ });
1545
+ }
1546
+ };
1547
+
1548
+ // src/auth/utils/diskStore.ts
1549
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "fs/promises";
1550
+ import { join } from "path";
1551
+ var DiskStore = class {
1552
+ cleanupInterval = null;
1553
+ directory;
1554
+ fileExtension;
1555
+ constructor(options) {
1556
+ this.directory = options.directory;
1557
+ this.fileExtension = options.fileExtension || ".json";
1558
+ void this.ensureDirectory();
1559
+ const cleanupIntervalMs = options.cleanupIntervalMs || 6e4;
1560
+ this.cleanupInterval = setInterval(() => {
1561
+ void this.cleanup();
1562
+ }, cleanupIntervalMs);
1563
+ }
1564
+ /**
1565
+ * Clean up expired entries
1566
+ */
1567
+ async cleanup() {
1568
+ try {
1569
+ await this.ensureDirectory();
1570
+ const files = await readdir(this.directory);
1571
+ const now = Date.now();
1572
+ for (const file of files) {
1573
+ if (!file.endsWith(this.fileExtension)) {
1574
+ continue;
1575
+ }
1576
+ try {
1577
+ const filePath = join(this.directory, file);
1578
+ const content = await readFile(filePath, "utf-8");
1579
+ const entry = JSON.parse(content);
1580
+ if (entry.expiresAt < now) {
1581
+ await rm(filePath);
1582
+ }
1583
+ } catch (error) {
1584
+ console.warn(`Failed to read/parse file ${file}, deleting:`, error);
1585
+ try {
1586
+ await rm(join(this.directory, file));
1587
+ } catch {
1588
+ }
1589
+ }
1590
+ }
1591
+ } catch (error) {
1592
+ console.error("Cleanup failed:", error);
1593
+ }
1594
+ }
1595
+ /**
1596
+ * Delete a value
1597
+ */
1598
+ async delete(key) {
1599
+ const filePath = this.getFilePath(key);
1600
+ try {
1601
+ await rm(filePath);
1602
+ } catch (error) {
1603
+ if (error.code !== "ENOENT") {
1604
+ console.error(`Failed to delete key ${key}:`, error);
1605
+ }
1606
+ }
1607
+ }
1608
+ /**
1609
+ * Destroy the storage and clear cleanup interval
1610
+ */
1611
+ destroy() {
1612
+ if (this.cleanupInterval) {
1613
+ clearInterval(this.cleanupInterval);
1614
+ this.cleanupInterval = null;
1615
+ }
1616
+ }
1617
+ /**
1618
+ * Retrieve a value
1619
+ */
1620
+ async get(key) {
1621
+ const filePath = this.getFilePath(key);
1622
+ try {
1623
+ const content = await readFile(filePath, "utf-8");
1624
+ const entry = JSON.parse(content);
1625
+ if (entry.expiresAt < Date.now()) {
1626
+ await rm(filePath);
1627
+ return null;
1628
+ }
1629
+ return entry.value;
1630
+ } catch (error) {
1631
+ if (error.code === "ENOENT") {
1632
+ return null;
1633
+ }
1634
+ console.error(`Failed to read key ${key}:`, error);
1635
+ return null;
1636
+ }
1637
+ }
1638
+ /**
1639
+ * Save a value with optional TTL
1640
+ */
1641
+ async save(key, value, ttl) {
1642
+ await this.ensureDirectory();
1643
+ const filePath = this.getFilePath(key);
1644
+ const expiresAt = ttl ? Date.now() + ttl * 1e3 : Number.MAX_SAFE_INTEGER;
1645
+ const entry = {
1646
+ expiresAt,
1647
+ value
1648
+ };
1649
+ try {
1650
+ await writeFile(filePath, JSON.stringify(entry, null, 2), "utf-8");
1651
+ } catch (error) {
1652
+ console.error(`Failed to save key ${key}:`, error);
1653
+ throw error;
1654
+ }
1655
+ }
1656
+ /**
1657
+ * Get the number of stored items
1658
+ */
1659
+ async size() {
1660
+ try {
1661
+ await this.ensureDirectory();
1662
+ const files = await readdir(this.directory);
1663
+ return files.filter((f) => f.endsWith(this.fileExtension)).length;
1664
+ } catch {
1665
+ return 0;
1666
+ }
1667
+ }
1668
+ /**
1669
+ * Ensure storage directory exists
1670
+ */
1671
+ async ensureDirectory() {
1672
+ try {
1673
+ const stats = await stat(this.directory);
1674
+ if (!stats.isDirectory()) {
1675
+ throw new Error(`Path ${this.directory} exists but is not a directory`);
1676
+ }
1677
+ } catch (error) {
1678
+ if (error.code === "ENOENT") {
1679
+ await mkdir(this.directory, { recursive: true });
1680
+ } else {
1681
+ throw error;
1682
+ }
1683
+ }
1684
+ }
1685
+ /**
1686
+ * Get file path for a key
1687
+ */
1688
+ getFilePath(key) {
1689
+ const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
1690
+ return join(this.directory, `${sanitizedKey}${this.fileExtension}`);
1691
+ }
1692
+ };
1693
+
1694
+ // src/auth/utils/jwks.ts
1695
+ var JWKSVerifier = class {
1696
+ config;
1697
+ jose;
1698
+ joseLoaded = false;
1699
+ jwksCache;
1700
+ constructor(config) {
1701
+ this.config = {
1702
+ cacheDuration: 36e5,
1703
+ // 1 hour
1704
+ cooldownDuration: 3e4,
1705
+ // 30 seconds
1706
+ ...config,
1707
+ audience: config.audience || "",
1708
+ issuer: config.issuer || ""
1709
+ };
1710
+ }
1711
+ /**
1712
+ * Get the JWKS URI being used
1713
+ */
1714
+ getJwksUri() {
1715
+ return this.config.jwksUri;
1716
+ }
1717
+ /**
1718
+ * Refresh the JWKS cache
1719
+ * Useful if you need to force a key refresh
1720
+ */
1721
+ async refreshKeys() {
1722
+ await this.loadJose();
1723
+ this.jwksCache = this.jose.createRemoteJWKSet(
1724
+ new URL(this.config.jwksUri),
1725
+ {
1726
+ cacheMaxAge: this.config.cacheDuration,
1727
+ cooldownDuration: this.config.cooldownDuration
1728
+ }
1729
+ );
1730
+ }
1731
+ /**
1732
+ * Verify a JWT token using JWKS
1733
+ *
1734
+ * @param token - The JWT token to verify
1735
+ * @returns Verification result with claims if valid
1736
+ *
1737
+ * @example
1738
+ * ```typescript
1739
+ * const result = await verifier.verify(token);
1740
+ * if (result.valid) {
1741
+ * console.log('User:', result.claims?.client_id);
1742
+ * } else {
1743
+ * console.error('Invalid token:', result.error);
1744
+ * }
1745
+ * ```
1746
+ */
1747
+ async verify(token) {
1748
+ try {
1749
+ await this.loadJose();
1750
+ const verifyOptions = {};
1751
+ if (this.config.audience) {
1752
+ verifyOptions.audience = this.config.audience;
1753
+ }
1754
+ if (this.config.issuer) {
1755
+ verifyOptions.issuer = this.config.issuer;
1756
+ }
1757
+ const { payload } = await this.jose.jwtVerify(
1758
+ token,
1759
+ this.jwksCache,
1760
+ verifyOptions
1761
+ );
1762
+ const claims = {
1763
+ aud: payload.aud,
1764
+ client_id: payload.client_id || payload.sub,
1765
+ exp: payload.exp,
1766
+ iat: payload.iat,
1767
+ iss: payload.iss,
1768
+ jti: payload.jti || "",
1769
+ scope: this.parseScope(payload.scope),
1770
+ ...payload
1771
+ // Include all other claims
1772
+ };
1773
+ return {
1774
+ claims,
1775
+ valid: true
1776
+ };
1777
+ } catch (error) {
1778
+ return {
1779
+ error: error.message || "Token verification failed",
1780
+ valid: false
1781
+ };
1782
+ }
1783
+ }
1784
+ /**
1785
+ * Lazy load the jose library
1786
+ * Only loads when verification is first attempted
1787
+ */
1788
+ async loadJose() {
1789
+ if (this.joseLoaded) {
1790
+ return;
1791
+ }
1792
+ try {
1793
+ this.jose = await import("jose");
1794
+ this.joseLoaded = true;
1795
+ this.jwksCache = this.jose.createRemoteJWKSet(
1796
+ new URL(this.config.jwksUri),
1797
+ {
1798
+ cacheMaxAge: this.config.cacheDuration,
1799
+ cooldownDuration: this.config.cooldownDuration
1800
+ }
1801
+ );
1802
+ } catch (error) {
1803
+ throw new Error(
1804
+ `JWKS verification requires the 'jose' package.
1805
+ Install it with: npm install jose
1806
+
1807
+ If you don't need JWKS support, use HS256 signing instead (default).
1808
+
1809
+ Original error: ${error.message}`
1810
+ );
1811
+ }
1812
+ }
1813
+ /**
1814
+ * Parse scope from token payload
1815
+ * Handles both string (space-separated) and array formats
1816
+ */
1817
+ parseScope(scope) {
1818
+ if (!scope) {
1819
+ return [];
1820
+ }
1821
+ if (typeof scope === "string") {
1822
+ return scope.split(" ").filter(Boolean);
1823
+ }
1824
+ if (Array.isArray(scope)) {
1825
+ return scope;
1826
+ }
1827
+ return [];
1828
+ }
1829
+ };
1830
+ export {
1831
+ AzureProvider,
1832
+ ConsentManager,
1833
+ DiskStore,
1834
+ EncryptedTokenStorage,
1835
+ GitHubProvider,
1836
+ GoogleProvider,
1837
+ JWKSVerifier,
1838
+ JWTIssuer,
1839
+ MemoryTokenStorage,
1840
+ OAuthProxy,
1841
+ OAuthProxyError,
1842
+ PKCEUtils
1843
+ };
1844
+ //# sourceMappingURL=index.js.map