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.
- package/README.md +43 -2
- package/dist/FastMCP.d.ts +11 -0
- package/dist/FastMCP.js +155 -0
- package/dist/FastMCP.js.map +1 -1
- package/dist/OAuthProxy-BOCkkAhO.d.ts +519 -0
- package/dist/auth/index.d.ts +445 -0
- package/dist/auth/index.js +1844 -0
- package/dist/auth/index.js.map +1 -0
- package/package.json +21 -2
|
@@ -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
|
+
"'": "'",
|
|
418
|
+
'"': """,
|
|
419
|
+
"/": "/",
|
|
420
|
+
"&": "&",
|
|
421
|
+
"<": "<",
|
|
422
|
+
">": ">"
|
|
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
|