// src/auth/OAuthProxy.ts
import { randomBytes as randomBytes4 } from "crypto";
// src/auth/utils/claimsExtractor.ts
var ClaimsExtractor = class {
config;
// Claims that MUST NOT be copied from upstream (protect proxy's JWT integrity)
PROTECTED_CLAIMS = /* @__PURE__ */ new Set([
"aud",
"client_id",
"exp",
"iat",
"iss",
"jti",
"nbf"
]);
constructor(config) {
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 (this.config.blockedClaims?.includes(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 = 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 = this.config.maxClaimValueSize ?? 2e3;
return stringified.length <= maxSize;
} catch {
return false;
}
}
return false;
}
};
// src/auth/utils/consent.ts
import { createHmac } from "crypto";
var ConsentManager = class {
signingKey;
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 {
return null;
}
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
const map = {
"'": "'",
'"': """,
"/": "/",
"&": "&",
"<": "<",
">": ">"
};
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 createHmac("sha256", this.signingKey).update(payload).digest("hex");
}
};
// src/auth/utils/jwtIssuer.ts
import { createHmac as createHmac2, pbkdf2, randomBytes } from "crypto";
import { promisify } from "util";
var pbkdf2Async = promisify(pbkdf2);
var JWTIssuer = class {
accessTokenTtl;
audience;
issuer;
refreshTokenTtl;
signingKey;
constructor(config) {
this.issuer = config.issuer;
this.audience = config.audience;
this.accessTokenTtl = config.accessTokenTtl || 3600;
this.refreshTokenTtl = config.refreshTokenTtl || 2592e3;
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) {
const now = Math.floor(Date.now() / 1e3);
const jti = this.generateJti();
const claims = {
aud: this.audience,
client_id: clientId,
exp: now + 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) {
const now = Math.floor(Date.now() / 1e3);
const jti = this.generateJti();
const claims = {
aud: this.audience,
client_id: clientId,
exp: now + 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 randomBytes(16).toString("base64url");
}
/**
* Sign data with HMAC-SHA256
*/
sign(data) {
const hmac = createHmac2("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
import { createHash, randomBytes as randomBytes2 } from "crypto";
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 = createHash("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 = randomBytes2(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
import {
createCipheriv,
createDecipheriv,
randomBytes as randomBytes3,
scryptSync
} from "crypto";
var EncryptedTokenStorage = class {
algorithm = "aes-256-gcm";
backend;
encryptionKey;
constructor(backend, encryptionKey) {
this.backend = backend;
const salt = Buffer.from("fastmcp-oauth-proxy-salt");
this.encryptionKey = scryptSync(encryptionKey, salt, 32);
}
async cleanup() {
await this.backend.cleanup();
}
async delete(key) {
await this.backend.delete(key);
}
async get(key) {
const encrypted = await this.backend.get(key);
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 save(key, value, ttl) {
const encrypted = await this.encrypt(
JSON.stringify(value),
this.encryptionKey
);
await this.backend.save(key, encrypted, ttl);
}
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 = createDecipheriv(this.algorithm, key, iv);
decipher.setAuthTag(
authTag
);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
async encrypt(plaintext, key) {
const iv = randomBytes3(16);
const cipher = createCipheriv(this.algorithm, key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
};
var MemoryTokenStorage = class {
cleanupInterval = null;
store = /* @__PURE__ */ new Map();
constructor(cleanupIntervalMs = 6e4) {
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;
}
};
// src/auth/OAuthProxy.ts
var OAuthProxy = class {
claimsExtractor = null;
cleanupInterval = null;
clientCodes = /* @__PURE__ */ new Map();
config;
consentManager;
jwtIssuer;
registeredClients = /* @__PURE__ */ new Map();
tokenStorage;
transactions = /* @__PURE__ */ new Map();
constructor(config) {
this.config = {
allowedRedirectUriPatterns: ["https://*", "http://localhost:*"],
authorizationCodeTtl: 300,
// 5 minutes
consentRequired: true,
enableTokenSwap: true,
// Enabled by default for security
redirectPath: "/oauth/callback",
transactionTtl: 600,
// 10 minutes
...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.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"
);
}
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.transactions.clear();
this.clientCodes.clear();
this.registeredClients.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 clientCode = this.clientCodes.get(request.code);
if (!clientCode) {
throw new OAuthProxyError(
"invalid_grant",
"Invalid or expired authorization code"
);
}
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 (clientCode.used) {
throw new OAuthProxyError(
"invalid_grant",
"Authorization code already used"
);
}
clientCode.used = true;
this.clientCodes.set(request.code, clientCode);
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"
);
}
const tokenResponse = await fetch(this.config.upstreamTokenEndpoint, {
body: new URLSearchParams({
client_id: this.config.upstreamClientId,
client_secret: this.config.upstreamClientSecret,
grant_type: "refresh_token",
refresh_token: request.refresh_token,
...request.scope && { scope: request.scope }
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
method: "POST"
});
if (!tokenResponse.ok) {
const error = await tokenResponse.json();
throw new OAuthProxyError(
error.error || "invalid_grant",
error.error_description
);
}
const tokens = await tokenResponse.json();
return {
access_token: tokens.access_token,
expires_in: tokens.expires_in,
id_token: tokens.id_token,
refresh_token: tokens.refresh_token,
scope: tokens.scope,
token_type: tokens.token_type || "Bearer"
};
}
/**
* 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 = this.transactions.get(state);
if (!transaction) {
throw new OAuthProxyError("invalid_request", "Invalid or expired state");
}
const upstreamTokens = await this.exchangeUpstreamCode(code, transaction);
const clientCode = this.generateAuthorizationCode(
transaction,
upstreamTokens
);
this.transactions.delete(state);
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 (!transactionId) {
throw new OAuthProxyError("invalid_request", "Missing transaction_id");
}
const transaction = this.transactions.get(transactionId);
if (!transaction) {
throw new OAuthProxyError(
"invalid_request",
"Invalid or expired transaction"
);
}
if (action === "deny") {
this.transactions.delete(transactionId);
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
});
}
transaction.consentGiven = true;
this.transactions.set(transactionId, transaction);
return this.redirectToUpstream(transaction);
}
/**
* 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 || !result.claims?.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 clientId = this.config.upstreamClientId;
const client = {
callbackUrl: request.redirect_uris[0],
clientId,
clientSecret: this.config.upstreamClientSecret,
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
},
registeredAt: /* @__PURE__ */ new Date()
};
this.registeredClients.set(request.redirect_uris[0], client);
const response = {
client_id: clientId,
client_id_issued_at: Math.floor(Date.now() / 1e3),
// Echo back optional metadata
client_name: request.client_name,
client_secret: this.config.upstreamClientSecret,
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;
}
/**
* Clean up expired transactions and codes
*/
cleanup() {
const now = Date.now();
for (const [id, transaction] of this.transactions.entries()) {
if (transaction.expiresAt.getTime() < now) {
this.transactions.delete(id);
}
}
for (const [code, clientCode] of this.clientCodes.entries()) {
if (clientCode.expiresAt.getTime() < now) {
this.clientCodes.delete(code);
}
}
void this.tokenStorage.cleanup();
}
/**
* 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()
};
this.transactions.set(transactionId, transaction);
return transaction;
}
/**
* Exchange authorization code with upstream provider
*/
async exchangeUpstreamCode(code, transaction) {
const tokenResponse = await fetch(this.config.upstreamTokenEndpoint, {
body: new URLSearchParams({
client_id: this.config.upstreamClientId,
client_secret: this.config.upstreamClientSecret,
code,
code_verifier: transaction.proxyCodeVerifier,
grant_type: "authorization_code",
redirect_uri: `${this.config.baseUrl}${this.config.redirectPath}`
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
method: "POST"
});
if (!tokenResponse.ok) {
const error = await tokenResponse.json();
throw new OAuthProxyError(
error.error || "server_error",
error.error_description
);
}
const tokens = await tokenResponse.json();
return {
accessToken: tokens.access_token,
expiresIn: tokens.expires_in || 3600,
idToken: tokens.id_token,
issuedAt: /* @__PURE__ */ new Date(),
refreshToken: tokens.refresh_token,
scope: tokens.scope ? tokens.scope.split(" ") : transaction.scope,
tokenType: tokens.token_type || "Bearer"
};
}
/**
* Extract JTI from a JWT token
*/
async extractJti(token) {
if (!this.jwtIssuer) {
throw new Error("JWT issuer not initialized");
}
const result = await this.jwtIssuer.verify(token);
if (!result.valid || !result.claims?.jti) {
throw new Error("Failed to extract JTI from token");
}
return result.claims.jti;
}
/**
* Extract custom claims from upstream tokens
* Combines claims from access token and ID token (if present)
*/
async extractUpstreamClaims(upstreamTokens) {
if (!this.claimsExtractor) {
return null;
}
const allClaims = {};
const accessClaims = await this.claimsExtractor.extract(
upstreamTokens.accessToken,
"access"
);
if (accessClaims) {
Object.assign(allClaims, accessClaims);
}
if (upstreamTokens.idToken) {
const idClaims = await this.claimsExtractor.extract(
upstreamTokens.idToken,
"id"
);
if (idClaims) {
for (const [key, value] of Object.entries(idClaims)) {
if (!(key in allClaims)) {
allClaims[key] = value;
}
}
}
}
return Object.keys(allClaims).length > 0 ? allClaims : null;
}
/**
* Generate authorization code for client
*/
generateAuthorizationCode(transaction, upstreamTokens) {
const code = this.generateId();
const clientCode = {
clientId: transaction.clientId,
code,
codeChallenge: transaction.clientCodeChallenge,
codeChallengeMethod: transaction.clientCodeChallengeMethod,
createdAt: /* @__PURE__ */ new Date(),
expiresAt: new Date(
Date.now() + (this.config.authorizationCodeTtl || 300) * 1e3
),
transactionId: transaction.id,
upstreamTokens
};
this.clientCodes.set(code, clientCode);
return code;
}
/**
* Generate secure random ID
*/
generateId() {
return randomBytes4(32).toString("base64url");
}
/**
* Generate signing key for consent cookies
*/
generateSigningKey() {
return randomBytes4(32).toString("hex");
}
/**
* Get provider name for display
*/
getProviderName() {
const url = new URL(this.config.upstreamAuthorizationEndpoint);
return url.hostname;
}
/**
* Issue swapped tokens (JWT pattern)
* Issues short-lived FastMCP JWTs and stores upstream tokens securely
*/
async issueSwappedTokens(clientId, upstreamTokens) {
if (!this.jwtIssuer) {
throw new Error("JWT issuer not initialized");
}
const customClaims = await this.extractUpstreamClaims(upstreamTokens);
const upstreamTokenKey = this.generateId();
await this.tokenStorage.save(
`upstream:${upstreamTokenKey}`,
upstreamTokens,
upstreamTokens.expiresIn
);
const accessToken = this.jwtIssuer.issueAccessToken(
clientId,
upstreamTokens.scope,
customClaims || void 0
);
const accessJti = await this.extractJti(accessToken);
await this.tokenStorage.save(
`mapping:${accessJti}`,
{
clientId,
createdAt: /* @__PURE__ */ new Date(),
expiresAt: new Date(Date.now() + upstreamTokens.expiresIn * 1e3),
jti: accessJti,
scope: upstreamTokens.scope,
upstreamTokenKey
},
upstreamTokens.expiresIn
);
const response = {
access_token: accessToken,
expires_in: 3600,
// FastMCP JWT expiration (1 hour)
scope: upstreamTokens.scope.join(" "),
token_type: "Bearer"
};
if (upstreamTokens.refreshToken) {
const refreshToken = this.jwtIssuer.issueRefreshToken(
clientId,
upstreamTokens.scope,
customClaims || void 0
);
const refreshJti = await this.extractJti(refreshToken);
await this.tokenStorage.save(
`mapping:${refreshJti}`,
{
clientId,
createdAt: /* @__PURE__ */ new Date(),
expiresAt: new Date(Date.now() + 2592e3 * 1e3),
// 30 days
jti: refreshJti,
scope: upstreamTokens.scope,
upstreamTokenKey
},
2592e3
// 30 days
);
response.refresh_token = refreshToken;
}
return response;
}
/**
* Match URI against pattern (supports wildcards)
*/
matchesPattern(uri, pattern) {
const regex = new RegExp(
"^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
);
return regex.test(uri);
}
/**
* Redirect to upstream OAuth provider
*/
redirectToUpstream(transaction) {
const authUrl = new URL(this.config.upstreamAuthorizationEndpoint);
authUrl.searchParams.set("client_id", this.config.upstreamClientId);
authUrl.searchParams.set(
"redirect_uri",
`${this.config.baseUrl}${this.config.redirectPath}`
);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("state", transaction.id);
if (transaction.scope.length > 0) {
authUrl.searchParams.set("scope", transaction.scope.join(" "));
}
if (!this.config.forwardPkce) {
authUrl.searchParams.set(
"code_challenge",
transaction.proxyCodeChallenge
);
authUrl.searchParams.set("code_challenge_method", "S256");
}
return new Response(null, {
headers: {
Location: authUrl.toString()
},
status: 302
});
}
/**
* Start periodic cleanup of expired transactions and codes
*/
startCleanup() {
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 6e4);
}
/**
* Validate redirect URI against allowed patterns
*/
validateRedirectUri(uri) {
try {
const url = new URL(uri);
const patterns = this.config.allowedRedirectUriPatterns || [];
for (const pattern of patterns) {
if (this.matchesPattern(uri, pattern)) {
return true;
}
}
return url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1";
} catch {
return false;
}
}
};
var OAuthProxyError = class extends Error {
constructor(code, description, statusCode = 400) {
super(code);
this.code = code;
this.description = description;
this.statusCode = statusCode;
this.name = "OAuthProxyError";
}
toJSON() {
return {
error: this.code,
error_description: this.description
};
}
toResponse() {
return new Response(JSON.stringify(this.toJSON()), {
headers: { "Content-Type": "application/json" },
status: this.statusCode
});
}
};
// src/auth/providers/AzureProvider.ts
var AzureProvider = class extends OAuthProxy {
constructor(config) {
const tenantId = config.tenantId || "common";
super({
baseUrl: config.baseUrl,
consentRequired: config.consentRequired,
scopes: config.scopes || ["openid", "profile", "email"],
upstreamAuthorizationEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`,
upstreamClientId: config.clientId,
upstreamClientSecret: config.clientSecret,
upstreamTokenEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`
});
}
};
// src/auth/providers/GitHubProvider.ts
var GitHubProvider = class extends OAuthProxy {
constructor(config) {
super({
baseUrl: config.baseUrl,
consentRequired: config.consentRequired,
scopes: config.scopes || ["read:user", "user:email"],
upstreamAuthorizationEndpoint: "https://github.com/login/oauth/authorize",
upstreamClientId: config.clientId,
upstreamClientSecret: config.clientSecret,
upstreamTokenEndpoint: "https://github.com/login/oauth/access_token"
});
}
};
// src/auth/providers/GoogleProvider.ts
var GoogleProvider = class extends OAuthProxy {
constructor(config) {
super({
baseUrl: config.baseUrl,
consentRequired: config.consentRequired,
scopes: config.scopes || ["openid", "profile", "email"],
upstreamAuthorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
upstreamClientId: config.clientId,
upstreamClientSecret: config.clientSecret,
upstreamTokenEndpoint: "https://oauth2.googleapis.com/token"
});
}
};
// src/auth/utils/diskStore.ts
import { mkdir, readdir, readFile, rm, stat, writeFile } from "fs/promises";
import { join } from "path";
var DiskStore = class {
cleanupInterval = null;
directory;
fileExtension;
constructor(options) {
this.directory = options.directory;
this.fileExtension = options.fileExtension || ".json";
void this.ensureDirectory();
const cleanupIntervalMs = options.cleanupIntervalMs || 6e4;
this.cleanupInterval = setInterval(() => {
void this.cleanup();
}, cleanupIntervalMs);
}
/**
* Clean up expired entries
*/
async cleanup() {
try {
await this.ensureDirectory();
const files = await readdir(this.directory);
const now = Date.now();
for (const file of files) {
if (!file.endsWith(this.fileExtension)) {
continue;
}
try {
const filePath = join(this.directory, file);
const content = await readFile(filePath, "utf-8");
const entry = JSON.parse(content);
if (entry.expiresAt < now) {
await rm(filePath);
}
} catch (error) {
console.warn(`Failed to read/parse file ${file}, deleting:`, error);
try {
await rm(join(this.directory, file));
} catch {
}
}
}
} catch (error) {
console.error("Cleanup failed:", error);
}
}
/**
* Delete a value
*/
async delete(key) {
const filePath = this.getFilePath(key);
try {
await rm(filePath);
} catch (error) {
if (error.code !== "ENOENT") {
console.error(`Failed to delete key ${key}:`, error);
}
}
}
/**
* Destroy the storage and clear cleanup interval
*/
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
}
/**
* Retrieve a value
*/
async get(key) {
const filePath = this.getFilePath(key);
try {
const content = await readFile(filePath, "utf-8");
const entry = JSON.parse(content);
if (entry.expiresAt < Date.now()) {
await rm(filePath);
return null;
}
return entry.value;
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
console.error(`Failed to read key ${key}:`, error);
return null;
}
}
/**
* Save a value with optional TTL
*/
async save(key, value, ttl) {
await this.ensureDirectory();
const filePath = this.getFilePath(key);
const expiresAt = ttl ? Date.now() + ttl * 1e3 : Number.MAX_SAFE_INTEGER;
const entry = {
expiresAt,
value
};
try {
await writeFile(filePath, JSON.stringify(entry, null, 2), "utf-8");
} catch (error) {
console.error(`Failed to save key ${key}:`, error);
throw error;
}
}
/**
* Get the number of stored items
*/
async size() {
try {
await this.ensureDirectory();
const files = await readdir(this.directory);
return files.filter((f) => f.endsWith(this.fileExtension)).length;
} catch {
return 0;
}
}
/**
* Ensure storage directory exists
*/
async ensureDirectory() {
try {
const stats = await stat(this.directory);
if (!stats.isDirectory()) {
throw new Error(`Path ${this.directory} exists but is not a directory`);
}
} catch (error) {
if (error.code === "ENOENT") {
await mkdir(this.directory, { recursive: true });
} else {
throw error;
}
}
}
/**
* Get file path for a key
*/
getFilePath(key) {
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
return join(this.directory, `${sanitizedKey}${this.fileExtension}`);
}
};
// src/auth/utils/jwks.ts
var JWKSVerifier = class {
config;
jose;
joseLoaded = false;
jwksCache;
constructor(config) {
this.config = {
cacheDuration: 36e5,
// 1 hour
cooldownDuration: 3e4,
// 30 seconds
...config,
audience: config.audience || "",
issuer: config.issuer || ""
};
}
/**
* Get the JWKS URI being used
*/
getJwksUri() {
return this.config.jwksUri;
}
/**
* Refresh the JWKS cache
* Useful if you need to force a key refresh
*/
async refreshKeys() {
await this.loadJose();
this.jwksCache = this.jose.createRemoteJWKSet(
new URL(this.config.jwksUri),
{
cacheMaxAge: this.config.cacheDuration,
cooldownDuration: this.config.cooldownDuration
}
);
}
/**
* Verify a JWT token using JWKS
*
* @param token - The JWT token to verify
* @returns Verification result with claims if valid
*
* @example
* ```typescript
* const result = await verifier.verify(token);
* if (result.valid) {
* console.log('User:', result.claims?.client_id);
* } else {
* console.error('Invalid token:', result.error);
* }
* ```
*/
async verify(token) {
try {
await this.loadJose();
const verifyOptions = {};
if (this.config.audience) {
verifyOptions.audience = this.config.audience;
}
if (this.config.issuer) {
verifyOptions.issuer = this.config.issuer;
}
const { payload } = await this.jose.jwtVerify(
token,
this.jwksCache,
verifyOptions
);
const claims = {
aud: payload.aud,
client_id: payload.client_id || payload.sub,
exp: payload.exp,
iat: payload.iat,
iss: payload.iss,
jti: payload.jti || "",
scope: this.parseScope(payload.scope),
...payload
// Include all other claims
};
return {
claims,
valid: true
};
} catch (error) {
return {
error: error.message || "Token verification failed",
valid: false
};
}
}
/**
* Lazy load the jose library
* Only loads when verification is first attempted
*/
async loadJose() {
if (this.joseLoaded) {
return;
}
try {
this.jose = await import("jose");
this.joseLoaded = true;
this.jwksCache = this.jose.createRemoteJWKSet(
new URL(this.config.jwksUri),
{
cacheMaxAge: this.config.cacheDuration,
cooldownDuration: this.config.cooldownDuration
}
);
} catch (error) {
throw new Error(
`JWKS verification requires the 'jose' package.
Install it with: npm install jose
If you don't need JWKS support, use HS256 signing instead (default).
Original error: ${error.message}`
);
}
}
/**
* Parse scope from token payload
* Handles both string (space-separated) and array formats
*/
parseScope(scope) {
if (!scope) {
return [];
}
if (typeof scope === "string") {
return scope.split(" ").filter(Boolean);
}
if (Array.isArray(scope)) {
return scope;
}
return [];
}
};
export {
AzureProvider,
ConsentManager,
DiskStore,
EncryptedTokenStorage,
GitHubProvider,
GoogleProvider,
JWKSVerifier,
JWTIssuer,
MemoryTokenStorage,
OAuthProxy,
OAuthProxyError,
PKCEUtils
};
//# sourceMappingURL=index.js.map