UNPKG

fastmcp

Version:

A TypeScript framework for building MCP servers.

1,615 lines (1,553 loc) 85.7 kB
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; var _class2; var _class3; var _class4; var _class5; var _class6;// src/auth/helpers.ts function getAuthSession(session) { if (!session) { throw new Error("Session is not authenticated"); } return session; } function requireAll(...checks) { return (auth) => checks.every( (check) => typeof check === "function" ? check(auth) : check ); } function requireAny(...checks) { return (auth) => checks.some((check) => typeof check === "function" ? check(auth) : check); } function requireAuth(auth) { return auth !== void 0 && auth !== null; } function requireRole(...allowedRoles) { return (auth) => { if (!auth) return false; const role = auth.role; return typeof role === "string" && allowedRoles.includes(role); }; } function requireScopes(...requiredScopes) { return (auth) => { if (!auth) return false; const authScopes = auth.scopes; if (!authScopes) return false; const scopeSet = Array.isArray(authScopes) ? new Set(authScopes) : authScopes instanceof Set ? authScopes : /* @__PURE__ */ new Set(); return requiredScopes.every((scope) => scopeSet.has(scope)); }; } // src/auth/OAuthProxy.ts var _crypto = require('crypto'); var _zod = require('zod'); // src/auth/OAuthProxyStateStore.ts var STORAGE_KEY_PREFIX = { client: "client:", code: "code:", transaction: "transaction:" }; var storedDateSchema = _zod.z.union([_zod.z.date(), _zod.z.string()]).transform((value, context) => { const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) { context.addIssue({ code: "custom", message: "Invalid stored date" }); return _zod.z.NEVER; } return date; }); var dcrClientMetadataSchema = _zod.z.object({ client_name: _zod.z.string().optional(), client_uri: _zod.z.string().optional(), contacts: _zod.z.array(_zod.z.string()).optional(), jwks: _zod.z.record(_zod.z.string(), _zod.z.unknown()).optional(), jwks_uri: _zod.z.string().optional(), logo_uri: _zod.z.string().optional(), policy_uri: _zod.z.string().optional(), scope: _zod.z.string().optional(), software_id: _zod.z.string().optional(), software_version: _zod.z.string().optional(), tos_uri: _zod.z.string().optional() }); var proxyDcrClientStorageSchema = _zod.z.object({ callbackUrl: _zod.z.string(), clientId: _zod.z.string(), clientSecret: _zod.z.string().optional(), metadata: dcrClientMetadataSchema.optional(), redirectUris: _zod.z.array(_zod.z.string()), registeredAt: storedDateSchema }); var upstreamTokenSetStorageSchema = _zod.z.object({ accessToken: _zod.z.string(), expiresIn: _zod.z.number(), idToken: _zod.z.string().optional(), issuedAt: storedDateSchema, refreshExpiresIn: _zod.z.number().optional(), refreshToken: _zod.z.string().optional(), scope: _zod.z.array(_zod.z.string()), tokenType: _zod.z.string() }); var clientCodeStorageSchema = _zod.z.object({ clientId: _zod.z.string(), code: _zod.z.string(), codeChallenge: _zod.z.string(), codeChallengeMethod: _zod.z.string(), createdAt: storedDateSchema, expiresAt: storedDateSchema, transactionId: _zod.z.string(), upstreamTokens: upstreamTokenSetStorageSchema, used: _zod.z.boolean().optional() }); var spentClientCodeStorageSchema = _zod.z.object({ expiresAt: storedDateSchema, used: _zod.z.literal(true) }); var oauthTransactionStorageSchema = _zod.z.object({ clientCallbackUrl: _zod.z.string(), clientCodeChallenge: _zod.z.string(), clientCodeChallengeMethod: _zod.z.string(), clientId: _zod.z.string(), consentGiven: _zod.z.boolean().optional(), createdAt: storedDateSchema, expiresAt: storedDateSchema, id: _zod.z.string(), metadata: _zod.z.record(_zod.z.string(), _zod.z.unknown()).optional(), proxyCodeChallenge: _zod.z.string(), proxyCodeVerifier: _zod.z.string(), scope: _zod.z.array(_zod.z.string()), state: _zod.z.string() }); var OAuthProxyStateStore = class { constructor(config) { this.registeredClientsByClientId = config.registeredClientsByClientId; this.tokenStorage = config.tokenStorage; } cacheRegisteredClient(client) { this.registeredClientsByClientId.set(client.clientId, client); } /** * Atomically consume an authorization code. At most one caller — across all * processes sharing the storage — can receive a given code, which is what * makes single use enforceable (RFC 6749 §4.1.2). * * Spent codes are taken too, so the caller must write the tombstone back * with `markClientCodeSpent` to keep reporting the precise error on the * attempt after this one. */ async consumeClientCode(code) { const stored = await this.takeFromStorage( `${STORAGE_KEY_PREFIX.code}${code}` ); const spent = spentClientCodeStorageSchema.safeParse(stored); if (spent.success) { return this.isExpired(spent.data.expiresAt) ? null : { expiresAt: spent.data.expiresAt, status: "spent" }; } const parsed = clientCodeStorageSchema.safeParse(stored); if (!parsed.success || this.isExpired(parsed.data.expiresAt)) { return null; } return { clientCode: parsed.data, expiresAt: parsed.data.expiresAt, status: "active" }; } /** * Atomically consume a transaction, so a callback can only be redeemed once * no matter which instance it lands on. */ async consumeTransaction(transactionId) { const stored = await this.takeFromStorage( `${STORAGE_KEY_PREFIX.transaction}${transactionId}` ); const parsed = oauthTransactionStorageSchema.safeParse(stored); if (!parsed.success || this.isExpired(parsed.data.expiresAt)) { return null; } return parsed.data; } async deleteTransaction(transactionId) { await this.tokenStorage.delete( `${STORAGE_KEY_PREFIX.transaction}${transactionId}` ); } async getRegisteredClientByClientId(clientId) { const cached = this.registeredClientsByClientId.get(clientId); if (cached) { return cached; } const stored = await this.tokenStorage.get( `${STORAGE_KEY_PREFIX.client}${clientId}` ); const parsed = proxyDcrClientStorageSchema.safeParse(stored); if (!parsed.success) { return null; } this.cacheRegisteredClient(parsed.data); return parsed.data; } /** * Read a transaction without consuming it. Used by the consent screen, which * has to hand the same transaction back to the upstream redirect. */ async getTransaction(transactionId) { const stored = await this.tokenStorage.get( `${STORAGE_KEY_PREFIX.transaction}${transactionId}` ); const parsed = oauthTransactionStorageSchema.safeParse(stored); if (!parsed.success) { return null; } if (this.isExpired(parsed.data.expiresAt)) { await this.deleteTransaction(transactionId); return null; } return parsed.data; } async isTransactionCallbackRegistered(transaction) { const registeredClient = await this.getRegisteredClientByClientId( transaction.clientId ); return _nullishCoalesce(_optionalChain([registeredClient, 'optionalAccess', _ => _.redirectUris, 'access', _2 => _2.includes, 'call', _3 => _3(transaction.clientCallbackUrl)]), () => ( false)); } /** * Record that an authorization code has been redeemed, so a later attempt * gets "already used" rather than looking like an unknown code. Keyed and * expiring exactly like the code it replaces, so it cannot outlive it. */ async markClientCodeSpent(code, expiresAt) { await this.tokenStorage.save( `${STORAGE_KEY_PREFIX.code}${code}`, { expiresAt, used: true }, this.getTtlSeconds(expiresAt) ); } async saveClientCode(clientCode) { await this.tokenStorage.save( `${STORAGE_KEY_PREFIX.code}${clientCode.code}`, clientCode, this.getTtlSeconds(clientCode.expiresAt) ); } async saveRegisteredClient(client) { await this.tokenStorage.save( `${STORAGE_KEY_PREFIX.client}${client.clientId}`, client ); } async saveTransaction(transaction) { await this.tokenStorage.save( `${STORAGE_KEY_PREFIX.transaction}${transaction.id}`, transaction, this.getTtlSeconds(transaction.expiresAt) ); } getTtlSeconds(expiresAt) { const millisecondsUntilExpiry = expiresAt.getTime() - Date.now(); return Math.max(Math.ceil(millisecondsUntilExpiry / 1e3), 1); } isExpired(expiresAt) { return expiresAt.getTime() < Date.now(); } /** * Prefer the storage's atomic take. Falling back to get + delete leaves a * window in which two processes can both observe the same value, so * multi-process deployments should implement `TokenStorage.take`. */ async takeFromStorage(key) { if (this.tokenStorage.take) { return this.tokenStorage.take(key); } const stored = await this.tokenStorage.get(key); if (stored === null) { return null; } await this.tokenStorage.delete(key); return stored; } }; // src/auth/types.ts var DEFAULT_ACCESS_TOKEN_TTL = 3600; var DEFAULT_ACCESS_TOKEN_TTL_NO_REFRESH = 31536e3; var DEFAULT_REFRESH_TOKEN_TTL = 2592e3; var DEFAULT_AUTHORIZATION_CODE_TTL = 300; var DEFAULT_TRANSACTION_TTL = 600; // src/auth/utils/claimsExtractor.ts var ClaimsExtractor = (_class = class { // Claims that MUST NOT be copied from upstream (protect proxy's JWT integrity) __init() {this.PROTECTED_CLAIMS = /* @__PURE__ */ new Set([ "aud", "client_id", "exp", "iat", "iss", "jti", "nbf" ])} constructor(config) {;_class.prototype.__init.call(this); if (typeof config === "boolean") { config = config ? {} : { fromAccessToken: false, fromIdToken: false }; } this.config = { allowComplexClaims: config.allowComplexClaims || false, allowedClaims: config.allowedClaims, blockedClaims: config.blockedClaims || [], claimPrefix: config.claimPrefix !== void 0 ? config.claimPrefix : false, // Default: no prefix fromAccessToken: config.fromAccessToken !== false, // Default: true fromIdToken: config.fromIdToken !== false, // Default: true maxClaimValueSize: config.maxClaimValueSize || 2e3 }; } /** * Extract claims from a token (access token or ID token) */ async extract(token, tokenType) { if (tokenType === "access" && !this.config.fromAccessToken) { return null; } if (tokenType === "id" && !this.config.fromIdToken) { return null; } if (!this.isJWT(token)) { return null; } const payload = this.decodeJWTPayload(token); if (!payload) { return null; } const filtered = this.filterClaims(payload); return this.applyPrefix(filtered); } /** * Apply prefix to claim names (if configured) */ applyPrefix(claims) { const prefix = this.config.claimPrefix; if (prefix === false || prefix === "" || prefix === void 0) { return claims; } const result = {}; for (const [key, value] of Object.entries(claims)) { result[`${prefix}${key}`] = value; } return result; } /** * Decode JWT payload without signature verification * Safe because token came from trusted upstream via server-to-server exchange */ decodeJWTPayload(token) { try { const parts = token.split("."); if (parts.length !== 3) { return null; } const payload = Buffer.from(parts[1], "base64url").toString("utf-8"); return JSON.parse(payload); } catch (error) { console.warn(`Failed to decode JWT payload: ${error}`); return null; } } /** * Filter claims based on security rules */ filterClaims(claims) { const result = {}; for (const [key, value] of Object.entries(claims)) { if (this.PROTECTED_CLAIMS.has(key)) { continue; } if (_optionalChain([this, 'access', _4 => _4.config, 'access', _5 => _5.blockedClaims, 'optionalAccess', _6 => _6.includes, 'call', _7 => _7(key)])) { continue; } if (this.config.allowedClaims && !this.config.allowedClaims.includes(key)) { continue; } if (!this.isValidClaimValue(value)) { console.warn(`Skipping claim '${key}' due to invalid value`); continue; } result[key] = value; } return result; } /** * Check if a token is in JWT format */ isJWT(token) { return token.split(".").length === 3; } /** * Validate a claim value (type and size checks) */ isValidClaimValue(value) { if (value === null || value === void 0) { return false; } const type = typeof value; if (type === "string") { const maxSize = _nullishCoalesce(this.config.maxClaimValueSize, () => ( 2e3)); return value.length <= maxSize; } if (type === "number" || type === "boolean") { return true; } if (Array.isArray(value) || type === "object") { if (!this.config.allowComplexClaims) { return false; } try { const stringified = JSON.stringify(value); const maxSize = _nullishCoalesce(this.config.maxClaimValueSize, () => ( 2e3)); return stringified.length <= maxSize; } catch (e) { return false; } } return false; } }, _class); // src/auth/utils/consent.ts var ConsentManager = class { constructor(signingKey) { this.signingKey = signingKey || this.generateDefaultKey(); } /** * Create HTTP response with consent screen */ createConsentResponse(transaction, provider) { const consentData = { clientName: "MCP Client", provider, scope: transaction.scope, timestamp: Date.now(), transactionId: transaction.id }; const html = this.generateConsentScreen(consentData); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" }, status: 200 }); } /** * Generate HTML for consent screen */ generateConsentScreen(data) { const { clientName, provider, scope, transactionId } = data; return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Authorization Request</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; justify-content: center; align-items: center; padding: 20px; } .consent-container { background: white; border-radius: 12px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); max-width: 480px; width: 100%; padding: 40px; } .header { text-align: center; margin-bottom: 30px; } .header h1 { color: #1a202c; font-size: 24px; margin-bottom: 8px; } .header p { color: #718096; font-size: 14px; } .app-info { background: #f7fafc; border-radius: 8px; padding: 20px; margin-bottom: 24px; } .app-info h2 { color: #2d3748; font-size: 18px; margin-bottom: 12px; } .app-name { color: #667eea; font-weight: 600; } .permissions { margin-top: 16px; } .permissions h3 { color: #4a5568; font-size: 14px; margin-bottom: 8px; font-weight: 600; } .permissions ul { list-style: none; } .permissions li { color: #718096; font-size: 14px; padding: 6px 0; padding-left: 24px; position: relative; } .permissions li:before { content: "\u2713"; position: absolute; left: 0; color: #48bb78; font-weight: bold; } .warning { background: #fffaf0; border-left: 4px solid #ed8936; padding: 12px 16px; margin-bottom: 24px; border-radius: 4px; } .warning p { color: #744210; font-size: 13px; line-height: 1.5; } .actions { display: flex; gap: 12px; } button { flex: 1; padding: 14px 24px; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; } .approve { background: #667eea; color: white; } .approve:hover { background: #5a67d8; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); } .deny { background: #e2e8f0; color: #4a5568; } .deny:hover { background: #cbd5e0; } .footer { margin-top: 24px; text-align: center; color: #a0aec0; font-size: 12px; } </style> </head> <body> <div class="consent-container"> <div class="header"> <h1>\u{1F510} Authorization Request</h1> <p>via ${this.escapeHtml(provider)}</p> </div> <div class="app-info"> <h2> <span class="app-name">${this.escapeHtml(clientName || "An application")}</span> requests access </h2> <div class="permissions"> <h3>This will allow the app to:</h3> <ul> ${scope.map((s) => `<li>${this.escapeHtml(this.formatScope(s))}</li>`).join("")} </ul> </div> </div> <div class="warning"> <p> <strong>\u26A0\uFE0F Important:</strong> Only approve if you trust this application. By approving, you authorize it to access your account information. </p> </div> <form method="POST" action="/oauth/consent"> <input type="hidden" name="transaction_id" value="${this.escapeHtml(transactionId)}"> <div class="actions"> <button type="submit" name="action" value="deny" class="deny"> Deny </button> <button type="submit" name="action" value="approve" class="approve"> Approve </button> </div> </form> <div class="footer"> <p>This consent is required to prevent unauthorized access.</p> </div> </div> </body> </html> `.trim(); } /** * Sign consent data for cookie */ signConsentCookie(data) { const payload = JSON.stringify(data); const signature = this.sign(payload); return `${Buffer.from(payload).toString("base64")}.${signature}`; } /** * Validate and parse consent cookie */ validateConsentCookie(cookie) { try { const [payloadB64, signature] = cookie.split("."); if (!payloadB64 || !signature) { return null; } const payload = Buffer.from(payloadB64, "base64").toString("utf8"); const expectedSignature = this.sign(payload); if (signature !== expectedSignature) { return null; } const data = JSON.parse(payload); const age = Date.now() - data.timestamp; if (age > 5 * 60 * 1e3) { return null; } return data; } catch (e2) { return null; } } /** * Escape HTML to prevent XSS */ escapeHtml(text) { const map = { "'": "&#x27;", '"': "&quot;", "/": "&#x2F;", "&": "&amp;", "<": "&lt;", ">": "&gt;" }; return text.replace(/[&<>"'/]/g, (char) => map[char] || char); } /** * Format scope for display */ formatScope(scope) { const scopeMap = { email: "Access your email address", openid: "Verify your identity", profile: "View your basic profile information", "read:user": "Read your user information", "write:user": "Modify your user information" }; return scopeMap[scope] || scope.replace(/_/g, " ").replace(/:/g, " - "); } /** * Generate default signing key if none provided */ generateDefaultKey() { return `fastmcp-consent-${Date.now()}-${Math.random()}`; } /** * Sign a payload using HMAC-SHA256 */ sign(payload) { return _crypto.createHmac.call(void 0, "sha256", this.signingKey).update(payload).digest("hex"); } }; // src/auth/utils/jwtIssuer.ts var _util = require('util'); var pbkdf2Async = _util.promisify.call(void 0, _crypto.pbkdf2); var JWTIssuer = class { constructor(config) { this.issuer = config.issuer; this.audience = config.audience; this.accessTokenTtl = config.accessTokenTtl || DEFAULT_ACCESS_TOKEN_TTL; this.refreshTokenTtl = config.refreshTokenTtl || DEFAULT_REFRESH_TOKEN_TTL; this.signingKey = Buffer.from(config.signingKey); } /** * Derive a signing key from a secret * Uses PBKDF2 for key derivation */ static async deriveKey(secret, iterations = 1e5) { const salt = Buffer.from("fastmcp-oauth-proxy"); const key = await pbkdf2Async(secret, salt, iterations, 32, "sha256"); return key.toString("base64"); } /** * Issue an access token */ issueAccessToken(clientId, scope, additionalClaims, expiresIn) { const now = Math.floor(Date.now() / 1e3); const jti = this.generateJti(); const claims = { aud: this.audience, client_id: clientId, exp: now + (_nullishCoalesce(expiresIn, () => ( this.accessTokenTtl))), iat: now, iss: this.issuer, jti, scope, // Merge additional claims (custom claims from upstream) ...additionalClaims || {} }; return this.signToken(claims); } /** * Issue a refresh token */ issueRefreshToken(clientId, scope, additionalClaims, expiresIn) { const now = Math.floor(Date.now() / 1e3); const jti = this.generateJti(); const claims = { aud: this.audience, client_id: clientId, exp: now + (_nullishCoalesce(expiresIn, () => ( this.refreshTokenTtl))), iat: now, iss: this.issuer, jti, scope, // Merge additional claims (custom claims from upstream) ...additionalClaims || {} }; return this.signToken(claims); } /** * Validate a JWT token */ async verify(token) { try { const parts = token.split("."); if (parts.length !== 3) { return { error: "Invalid token format", valid: false }; } const [headerB64, payloadB64, signatureB64] = parts; const expectedSignature = this.sign(`${headerB64}.${payloadB64}`); if (signatureB64 !== expectedSignature) { return { error: "Invalid signature", valid: false }; } const claims = JSON.parse( Buffer.from(payloadB64, "base64url").toString("utf-8") ); const now = Math.floor(Date.now() / 1e3); if (claims.exp <= now) { return { claims, error: "Token expired", valid: false }; } if (claims.iss !== this.issuer) { return { claims, error: "Invalid issuer", valid: false }; } if (claims.aud !== this.audience) { return { claims, error: "Invalid audience", valid: false }; } return { claims, valid: true }; } catch (error) { return { error: error instanceof Error ? error.message : "Validation failed", valid: false }; } } /** * Generate unique JWT ID */ generateJti() { return _crypto.randomBytes.call(void 0, 16).toString("base64url"); } /** * Sign data with HMAC-SHA256 */ sign(data) { const hmac = _crypto.createHmac.call(void 0, "sha256", this.signingKey); hmac.update(data); return hmac.digest("base64url"); } /** * Sign a JWT token */ signToken(claims) { const header = { alg: "HS256", typ: "JWT" }; const headerB64 = Buffer.from(JSON.stringify(header)).toString("base64url"); const payloadB64 = Buffer.from(JSON.stringify(claims)).toString( "base64url" ); const signature = this.sign(`${headerB64}.${payloadB64}`); return `${headerB64}.${payloadB64}.${signature}`; } }; // src/auth/utils/pkce.ts var PKCEUtils = class _PKCEUtils { /** * Generate a code challenge from a verifier * @param verifier The code verifier * @param method Challenge method: 'S256' or 'plain' (default: 'S256') * @returns Base64URL-encoded challenge string */ static generateChallenge(verifier, method = "S256") { if (method === "plain") { return verifier; } if (method === "S256") { const hash = _crypto.createHash.call(void 0, "sha256"); hash.update(verifier); return _PKCEUtils.base64URLEncode(hash.digest()); } throw new Error(`Unsupported challenge method: ${method}`); } /** * Generate a complete PKCE pair (verifier + challenge) * @param method Challenge method: 'S256' or 'plain' (default: 'S256') * @returns Object containing verifier and challenge */ static generatePair(method = "S256") { const verifier = _PKCEUtils.generateVerifier(); const challenge = _PKCEUtils.generateChallenge(verifier, method); return { challenge, verifier }; } /** * Generate a cryptographically secure code verifier * @param length Length of verifier (43-128 characters, default: 128) * @returns Base64URL-encoded verifier string */ static generateVerifier(length = 128) { if (length < 43 || length > 128) { throw new Error("PKCE verifier length must be between 43 and 128"); } const byteLength = Math.ceil(length * 3 / 4); const randomBytesBuffer = _crypto.randomBytes.call(void 0, byteLength); return _PKCEUtils.base64URLEncode(randomBytesBuffer).slice(0, length); } /** * Validate a code verifier against a challenge * @param verifier The code verifier to validate * @param challenge The expected challenge * @param method The challenge method used * @returns True if verifier matches challenge */ static validateChallenge(verifier, challenge, method) { if (!verifier || !challenge) { return false; } if (method === "plain") { return verifier === challenge; } if (method === "S256") { const computedChallenge = _PKCEUtils.generateChallenge(verifier, "S256"); return computedChallenge === challenge; } return false; } /** * Encode a buffer as base64url (RFC 4648) * @param buffer Buffer to encode * @returns Base64URL-encoded string */ static base64URLEncode(buffer) { return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } }; // src/auth/utils/tokenStore.ts var EncryptedTokenStorage = (_class2 = class { __init2() {this.algorithm = "aes-256-gcm"} constructor(backend, encryptionKey) {;_class2.prototype.__init2.call(this); this.backend = backend; const salt = Buffer.from("fastmcp-oauth-proxy-salt"); this.encryptionKey = _crypto.scryptSync.call(void 0, encryptionKey, salt, 32); } async cleanup() { await this.backend.cleanup(); } async delete(key) { await this.backend.delete(key); } async get(key) { return this.decryptStored(await this.backend.get(key)); } async save(key, value, ttl) { const encrypted = await this.encrypt( JSON.stringify(value), this.encryptionKey ); await this.backend.save(key, encrypted, ttl); } /** * Delegates to the backend so that the atomicity of the underlying store is * preserved; falls back to get + delete when the backend cannot do better. */ async take(key) { if (this.backend.take) { return this.decryptStored(await this.backend.take(key)); } const value = await this.get(key); if (value === null) { return null; } await this.backend.delete(key); return value; } async decrypt(ciphertext, key) { const parts = ciphertext.split(":"); if (parts.length !== 3) { throw new Error("Invalid encrypted data format"); } const [ivHex, authTagHex, encrypted] = parts; const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); const decipher = _crypto.createDecipheriv.call(void 0, this.algorithm, key, iv, { authTagLength: 16 }); decipher.setAuthTag( authTag ); let decrypted = decipher.update(encrypted, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } async decryptStored(encrypted) { if (!encrypted) { return null; } try { const decrypted = await this.decrypt( encrypted, this.encryptionKey ); return JSON.parse(decrypted); } catch (error) { console.error("Failed to decrypt value:", error); return null; } } async encrypt(plaintext, key) { const iv = _crypto.randomBytes.call(void 0, 16); const cipher = _crypto.createCipheriv.call(void 0, this.algorithm, key, iv, { authTagLength: 16 }); let encrypted = cipher.update(plaintext, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag(); return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; } }, _class2); var MemoryTokenStorage = (_class3 = class { __init3() {this.cleanupInterval = null} __init4() {this.store = /* @__PURE__ */ new Map()} constructor(cleanupIntervalMs = 6e4) {;_class3.prototype.__init3.call(this);_class3.prototype.__init4.call(this); this.cleanupInterval = setInterval( () => void this.cleanup(), cleanupIntervalMs ); } async cleanup() { const now = Date.now(); const keysToDelete = []; for (const [key, entry] of this.store.entries()) { if (entry.expiresAt < now) { keysToDelete.push(key); } } for (const key of keysToDelete) { this.store.delete(key); } } async delete(key) { this.store.delete(key); } /** * Destroy the storage and clear cleanup interval */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.store.clear(); } async get(key) { const entry = this.store.get(key); if (!entry) { return null; } if (entry.expiresAt < Date.now()) { this.store.delete(key); return null; } return entry.value; } async save(key, value, ttl) { const expiresAt = ttl ? Date.now() + ttl * 1e3 : Number.MAX_SAFE_INTEGER; this.store.set(key, { expiresAt, value }); } /** * Get the number of stored items */ size() { return this.store.size; } /** * Atomic by construction: the read and the delete happen in a single * synchronous block, so no other task can observe the value in between. */ async take(key) { const entry = this.store.get(key); this.store.delete(key); if (!entry || entry.expiresAt < Date.now()) { return null; } return entry.value; } }, _class3); // src/auth/OAuthProxy.ts var RESERVED_AUTHORIZATION_PARAMS = /* @__PURE__ */ new Set([ "client_id", "code_challenge", "code_challenge_method", "redirect_uri", "response_type", "scope", "state" ]); var OAuthProxy = (_class4 = class { __init5() {this.claimsExtractor = null} __init6() {this.cleanupInterval = null} /** * Keyed by proxy-issued client_id for authorize/token-exchange lookups and * for the defence-in-depth callback checks. A registration never changes * after it is written, so caching it locally cannot go stale; it is also * persisted, so another instance can hydrate it. */ __init7() {this.registeredClientsByClientId = /* @__PURE__ */ new Map()} constructor(config) {;_class4.prototype.__init5.call(this);_class4.prototype.__init6.call(this);_class4.prototype.__init7.call(this); this.config = { authorizationCodeTtl: DEFAULT_AUTHORIZATION_CODE_TTL, consentRequired: true, enableTokenSwap: true, // Enabled by default for security redirectPath: "/oauth/callback", transactionTtl: DEFAULT_TRANSACTION_TTL, upstreamTokenEndpointAuthMethod: "client_secret_basic", ...config }; let storage = config.tokenStorage || new MemoryTokenStorage(); const isAlreadyEncrypted = storage.constructor.name === "EncryptedTokenStorage"; if (!isAlreadyEncrypted && config.encryptionKey !== false) { const encryptionKey = typeof config.encryptionKey === "string" ? config.encryptionKey : this.generateSigningKey(); storage = new EncryptedTokenStorage(storage, encryptionKey); } this.tokenStorage = storage; this.stateStore = new OAuthProxyStateStore({ registeredClientsByClientId: this.registeredClientsByClientId, tokenStorage: this.tokenStorage }); this.consentManager = new ConsentManager( config.consentSigningKey || this.generateSigningKey() ); if (this.config.enableTokenSwap) { const signingKey = this.config.jwtSigningKey || this.generateSigningKey(); this.jwtIssuer = new JWTIssuer({ audience: this.config.baseUrl, issuer: this.config.baseUrl, signingKey }); } const claimsConfig = config.customClaimsPassthrough !== void 0 ? config.customClaimsPassthrough : true; if (claimsConfig !== false) { this.claimsExtractor = new ClaimsExtractor(claimsConfig); } this.startCleanup(); } /** * OAuth authorization endpoint */ async authorize(params) { if (!params.client_id || !params.redirect_uri || !params.response_type) { throw new OAuthProxyError( "invalid_request", "Missing required parameters" ); } if (params.response_type !== "code") { throw new OAuthProxyError( "unsupported_response_type", "Only 'code' response type is supported" ); } const registeredClient = await this.stateStore.getRegisteredClientByClientId(params.client_id); if (!registeredClient) { throw new OAuthProxyError("invalid_client", "Unknown client_id"); } if (!registeredClient.redirectUris.includes(params.redirect_uri)) { throw new OAuthProxyError( "invalid_request", "redirect_uri is not registered for this client" ); } if (params.code_challenge && !params.code_challenge_method) { throw new OAuthProxyError( "invalid_request", "code_challenge_method required when code_challenge is present" ); } const transaction = await this.createTransaction(params); if (this.config.consentRequired && !transaction.consentGiven) { return this.consentManager.createConsentResponse( transaction, this.getProviderName() ); } return this.redirectToUpstream(transaction); } /** * Stop cleanup interval and destroy resources */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.registeredClientsByClientId.clear(); } /** * Token endpoint - exchange authorization code for tokens */ async exchangeAuthorizationCode(request) { if (request.grant_type !== "authorization_code") { throw new OAuthProxyError( "unsupported_grant_type", "Only authorization_code grant type is supported" ); } const registeredClient = await this.stateStore.getRegisteredClientByClientId(request.client_id); if (!registeredClient) { throw new OAuthProxyError("invalid_client", "Unknown client_id"); } const consumed = await this.stateStore.consumeClientCode(request.code); if (!consumed) { throw new OAuthProxyError( "invalid_grant", "Invalid or expired authorization code" ); } await this.stateStore.markClientCodeSpent(request.code, consumed.expiresAt); if (consumed.status === "spent") { throw new OAuthProxyError( "invalid_grant", "Authorization code already used" ); } const clientCode = consumed.clientCode; if (clientCode.clientId !== request.client_id) { throw new OAuthProxyError("invalid_client", "Client ID mismatch"); } if (clientCode.codeChallenge) { if (!request.code_verifier) { throw new OAuthProxyError( "invalid_request", "code_verifier required for PKCE" ); } const valid = PKCEUtils.validateChallenge( request.code_verifier, clientCode.codeChallenge, clientCode.codeChallengeMethod ); if (!valid) { throw new OAuthProxyError("invalid_grant", "Invalid PKCE verifier"); } } if (this.config.enableTokenSwap && this.jwtIssuer) { return await this.issueSwappedTokens( clientCode.clientId, clientCode.upstreamTokens ); } else { const response = { access_token: clientCode.upstreamTokens.accessToken, expires_in: clientCode.upstreamTokens.expiresIn, token_type: clientCode.upstreamTokens.tokenType }; if (clientCode.upstreamTokens.refreshToken) { response.refresh_token = clientCode.upstreamTokens.refreshToken; } if (clientCode.upstreamTokens.idToken) { response.id_token = clientCode.upstreamTokens.idToken; } if (clientCode.upstreamTokens.scope.length > 0) { response.scope = clientCode.upstreamTokens.scope.join(" "); } return response; } } /** * Token endpoint - refresh access token */ async exchangeRefreshToken(request) { if (request.grant_type !== "refresh_token") { throw new OAuthProxyError( "unsupported_grant_type", "Only refresh_token grant type is supported" ); } if (this.config.enableTokenSwap && this.jwtIssuer) { return await this.handleSwapModeRefresh(request); } return await this.handlePassthroughRefresh(request); } /** * Get OAuth discovery metadata */ getAuthorizationServerMetadata() { return { authorizationEndpoint: `${this.config.baseUrl}/oauth/authorize`, codeChallengeMethodsSupported: ["S256", "plain"], grantTypesSupported: ["authorization_code", "refresh_token"], issuer: this.config.baseUrl, registrationEndpoint: `${this.config.baseUrl}/oauth/register`, responseTypesSupported: ["code"], scopesSupported: this.config.scopes || [], tokenEndpoint: `${this.config.baseUrl}/oauth/token`, tokenEndpointAuthMethodsSupported: [ "client_secret_basic", "client_secret_post" ] }; } /** * Handle OAuth callback from upstream provider */ async handleCallback(request) { const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const error = url.searchParams.get("error"); if (error) { const errorDescription = url.searchParams.get("error_description"); throw new OAuthProxyError(error, errorDescription || void 0); } if (!code || !state) { throw new OAuthProxyError( "invalid_request", "Missing code or state parameter" ); } const transaction = await this.stateStore.consumeTransaction(state); if (!transaction) { throw new OAuthProxyError("invalid_request", "Invalid or expired state"); } if (!await this.stateStore.isTransactionCallbackRegistered(transaction)) { throw new OAuthProxyError( "invalid_request", "Transaction callback URL is not registered" ); } const upstreamTokens = await this.exchangeUpstreamCode(code, transaction); const clientCode = await this.generateAuthorizationCode( transaction, upstreamTokens ); const redirectUrl = new URL(transaction.clientCallbackUrl); redirectUrl.searchParams.set("code", clientCode); redirectUrl.searchParams.set("state", transaction.state); return new Response(null, { headers: { Location: redirectUrl.toString() }, status: 302 }); } /** * Handle consent form submission */ async handleConsent(request) { const formData = await request.formData(); const transactionId = formData.get("transaction_id"); const action = formData.get("action"); if (typeof transactionId !== "string" || !transactionId) { throw new OAuthProxyError("invalid_request", "Missing transaction_id"); } if (action !== "approve" && action !== "deny") { throw new OAuthProxyError( "invalid_request", "action must be 'approve' or 'deny'" ); } const transaction = await this.stateStore.getTransaction(transactionId); if (!transaction) { throw new OAuthProxyError( "invalid_request", "Invalid or expired transaction" ); } if (action === "deny") { await this.stateStore.deleteTransaction(transactionId); if (!await this.stateStore.isTransactionCallbackRegistered(transaction)) { throw new OAuthProxyError( "invalid_request", "Transaction callback URL is not registered" ); } const redirectUrl = new URL(transaction.clientCallbackUrl); redirectUrl.searchParams.set("error", "access_denied"); redirectUrl.searchParams.set( "error_description", "User denied authorization" ); redirectUrl.searchParams.set("state", transaction.state); return new Response(null, { headers: { Location: redirectUrl.toString() }, status: 302 }); } const approvedTransaction = { ...transaction, consentGiven: true }; await this.stateStore.saveTransaction(approvedTransaction); return this.redirectToUpstream(approvedTransaction); } /** * Load upstream tokens from a FastMCP JWT */ async loadUpstreamTokens(fastmcpToken) { if (!this.jwtIssuer) { return null; } const result = await this.jwtIssuer.verify(fastmcpToken); if (!result.valid || !_optionalChain([result, 'access', _8 => _8.claims, 'optionalAccess', _9 => _9.jti])) { return null; } const mapping = await this.tokenStorage.get( `mapping:${result.claims.jti}` ); if (!mapping) { return null; } const upstreamTokens = await this.tokenStorage.get( `upstream:${mapping.upstreamTokenKey}` ); return upstreamTokens; } /** * RFC 7591 Dynamic Client Registration */ async registerClient(request) { if (!request.redirect_uris || request.redirect_uris.length === 0) { throw new OAuthProxyError( "invalid_client_metadata", "redirect_uris is required" ); } for (const uri of request.redirect_uris) { if (!this.validateRedirectUri(uri)) { throw new OAuthProxyError( "invalid_redirect_uri", `Invalid redirect URI: ${uri}` ); } } const proxyClientId = _crypto.randomBytes.call(void 0, 16).toString("hex"); const proxyClientSecret = _crypto.randomBytes.call(void 0, 32).toString("base64url"); const client = { callbackUrl: request.redirect_uris[0], clientId: proxyClientId, clientSecret: proxyClientSecret, metadata: { client_name: request.client_name, client_uri: request.client_uri, contacts: request.contacts, jwks: request.jwks, jwks_uri: request.jwks_uri, logo_uri: request.logo_uri, policy_uri: request.policy_uri, scope: request.scope, software_id: request.software_id, software_version: request.software_version, tos_uri: request.tos_uri }, redirectUris: request.redirect_uris, registeredAt: /* @__PURE__ */ new Date() }; this.stateStore.cacheRegisteredClient(client); await this.stateStore.saveRegisteredClient(client); const response = { client_id: proxyClientId, client_id_issued_at: Math.floor(Date.now() / 1e3), // Echo back optional metadata client_name: request.client_name, client_secret: proxyClientSecret, client_secret_expires_at: 0, // Never expires client_uri: request.client_uri, contacts: request.contacts, grant_types: request.grant_types || [ "authorization_code", "refresh_token" ], jwks: request.jwks, jwks_uri: request.jwks_uri, logo_uri: request.logo_uri, policy_uri: request.policy_uri, redirect_uris: request.redirect_uris, response_types: request.response_types || ["code"], scope: request.scope, software_id: request.software_id, software_version: request.software_version, token_endpoint_auth_method: request.token_endpoint_auth_method || "client_secret_basic", tos_uri: request.tos_uri }; return response; } /** * Calculate access token TTL from upstream tokens */ calculateAccessTokenTtl(upstreamTokens) { if (upstreamTokens.expiresIn > 0) { return upstreamTokens.expiresIn; } else if (this.config.accessTokenTtl) { return this.config.accessTokenTtl; } else if (upstreamTokens.refreshToken) { return DEFAULT_ACCESS_TOKEN_TTL; } else { return DEFAULT_ACCESS_TOKEN_TTL_NO_REFRESH; } } /** * Periodic maintenance hook. Expiry is enforced by the token storage. */ cleanup() { void this.tokenStorage.cleanup().catch((error) => { console.error( "[FastMCP] OAuth proxy token storage cleanup failed:", error instanceof Error ? error.message : String(error) ); }); } /** * Create a new OAuth transaction */ async createTransaction(params) { const transactionId = this.generateId(); const proxyPkce = PKCEUtils.generatePair("S256"); const transaction = { clientCallbackUrl: params.redirect_uri, clientCodeChallenge: params.code_challenge || "", clientCodeChallengeMethod: params.code_challenge_method || "plain", clientId: params.client_id, createdAt: /* @__PURE__ */ new Date(), expiresAt: new Date( Date.now() + (this.config.transactionTtl || 600) * 1e3 ), id: transactionId, proxyCodeChallenge: proxyPkce.challenge, proxyCodeVerifier: proxyPkce.verifier, scope: params.scope ? params.scope.split(" ") : this.config.scopes || [], state: params.state || this.generateId() }; await this.stateStore.saveTransaction(transaction); return transaction; } /** * Exchange authorization code with upstream provider */ async exchangeUpstreamCode(code, transaction) { const useBasicAuth = this.config.upstreamTokenEndpointAuthMethod === "client_secret_basic"; const bodyParams = { code, code_verifier: transaction.proxyCodeVerifier, grant_type: "authorization_code", redirect_uri: `${this.config.baseUrl}${this.config.redirectPath}` }; if (!useBasicAuth) { bodyParams.client_id = this.config.upstreamClientId; bodyParams.client_secret = this.config.upstreamClientSecret; } const headers = { "Content-Type": "application/x-www-form-urlencoded" }; if (useBasicAuth) { headers["Authorization"] = this.getBasicAuthHeader(); } const tokenResponse = await fetch(this.config.upstreamTokenEndpoint, { body: new URLSearchParams(bodyParams), headers, method: "POST" }); if (!tokenResponse.ok) { let errorCode = "server_error"; let errorDescription; try { const error = await tokenResponse.json(); errorCode = error.error || "server_error"; errorDescription = error.error_description; } catch (e3) { errorDescription = `Upstream returned HTTP ${tokenResponse.status} ${tokenResponse.statusText}`; } throw new OAuthProxyError(errorCode, errorDescription); } const tokens = await this.parseTokenResponse(tokenResponse); return { accessToken: tokens.access_token, expiresIn: tokens.expires_in || 3600, idToken: tokens.id_toke