2328 lines
66 KiB
JavaScript
2328 lines
66 KiB
JavaScript
// 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
|
|
import { randomBytes as randomBytes4 } from "crypto";
|
|
import { z } from "zod";
|
|
|
|
// 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 {
|
|
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 || 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 + (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 + (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 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: 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.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"
|
|
);
|
|
}
|
|
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 = 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;
|
|
}
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
/**
|
|
* 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 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) {
|
|
const error = await tokenResponse.json();
|
|
throw new OAuthProxyError(
|
|
error.error || "server_error",
|
|
error.error_description
|
|
);
|
|
}
|
|
const tokens = await this.parseTokenResponse(tokenResponse);
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
expiresIn: tokens.expires_in || 3600,
|
|
idToken: tokens.id_token,
|
|
issuedAt: /* @__PURE__ */ new Date(),
|
|
refreshExpiresIn: tokens.refresh_expires_in,
|
|
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");
|
|
}
|
|
/**
|
|
* Generate Basic auth header value for upstream token endpoint
|
|
* Per RFC 6749 Section 2.3.1, credentials must be URL-encoded before base64 encoding
|
|
*/
|
|
getBasicAuthHeader() {
|
|
const encodedClientId = encodeURIComponent(this.config.upstreamClientId);
|
|
const encodedClientSecret = encodeURIComponent(
|
|
this.config.upstreamClientSecret
|
|
);
|
|
return `Basic ${Buffer.from(`${encodedClientId}:${encodedClientSecret}`).toString("base64")}`;
|
|
}
|
|
/**
|
|
* Get provider name for display
|
|
*/
|
|
getProviderName() {
|
|
const url = new URL(this.config.upstreamAuthorizationEndpoint);
|
|
return url.hostname;
|
|
}
|
|
/**
|
|
* Handle passthrough mode refresh - forward refresh token directly to upstream
|
|
*/
|
|
async handlePassthroughRefresh(request) {
|
|
const useBasicAuth = this.config.upstreamTokenEndpointAuthMethod === "client_secret_basic";
|
|
const bodyParams = {
|
|
grant_type: "refresh_token",
|
|
refresh_token: request.refresh_token,
|
|
...request.scope && { scope: request.scope }
|
|
};
|
|
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) {
|
|
const error = await tokenResponse.json();
|
|
throw new OAuthProxyError(
|
|
error.error || "invalid_grant",
|
|
error.error_description
|
|
);
|
|
}
|
|
const tokens = await this.parseTokenResponse(tokenResponse);
|
|
return {
|
|
access_token: tokens.access_token,
|
|
expires_in: tokens.expires_in || 3600,
|
|
id_token: tokens.id_token,
|
|
refresh_token: tokens.refresh_token,
|
|
scope: tokens.scope,
|
|
token_type: tokens.token_type || "Bearer"
|
|
};
|
|
}
|
|
/**
|
|
* Handle swap mode refresh - verify FastMCP JWT and issue new tokens
|
|
*/
|
|
async handleSwapModeRefresh(request) {
|
|
if (!this.jwtIssuer) {
|
|
throw new Error("JWT issuer not initialized");
|
|
}
|
|
const verifyResult = await this.jwtIssuer.verify(request.refresh_token);
|
|
if (!verifyResult.valid) {
|
|
throw new OAuthProxyError(
|
|
"invalid_grant",
|
|
"Invalid or expired refresh token"
|
|
);
|
|
}
|
|
const jti = verifyResult.claims?.jti;
|
|
if (!jti) {
|
|
throw new OAuthProxyError("invalid_grant", "Refresh token missing JTI");
|
|
}
|
|
const mapping = await this.tokenStorage.get(`mapping:${jti}`);
|
|
if (!mapping) {
|
|
throw new OAuthProxyError(
|
|
"invalid_grant",
|
|
"Refresh token already used or expired"
|
|
);
|
|
}
|
|
const upstreamTokens = await this.tokenStorage.get(
|
|
`upstream:${mapping.upstreamTokenKey}`
|
|
);
|
|
if (!upstreamTokens) {
|
|
throw new OAuthProxyError(
|
|
"invalid_grant",
|
|
"Upstream tokens not found or expired"
|
|
);
|
|
}
|
|
if (!upstreamTokens.refreshToken) {
|
|
throw new OAuthProxyError(
|
|
"invalid_grant",
|
|
"No upstream refresh token available"
|
|
);
|
|
}
|
|
const refreshedUpstreamTokens = await this.refreshUpstreamTokens(
|
|
upstreamTokens.refreshToken,
|
|
request.scope
|
|
);
|
|
if (refreshedUpstreamTokens.scope.length === 0) {
|
|
refreshedUpstreamTokens.scope = upstreamTokens.scope;
|
|
}
|
|
const refreshTokenTtl = refreshedUpstreamTokens.refreshExpiresIn ?? this.config.refreshTokenTtl ?? DEFAULT_REFRESH_TOKEN_TTL;
|
|
const accessTokenTtl = this.calculateAccessTokenTtl(
|
|
refreshedUpstreamTokens
|
|
);
|
|
const upstreamStorageTtl = Math.max(accessTokenTtl, refreshTokenTtl, 1);
|
|
await this.tokenStorage.save(
|
|
`upstream:${mapping.upstreamTokenKey}`,
|
|
refreshedUpstreamTokens,
|
|
upstreamStorageTtl
|
|
);
|
|
return await this.issueSwappedTokensForRefresh(
|
|
mapping.clientId,
|
|
refreshedUpstreamTokens,
|
|
mapping.upstreamTokenKey,
|
|
jti
|
|
);
|
|
}
|
|
/**
|
|
* 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);
|
|
let accessTokenTtl;
|
|
if (upstreamTokens.expiresIn > 0) {
|
|
accessTokenTtl = upstreamTokens.expiresIn;
|
|
} else if (this.config.accessTokenTtl) {
|
|
accessTokenTtl = this.config.accessTokenTtl;
|
|
} else if (upstreamTokens.refreshToken) {
|
|
accessTokenTtl = DEFAULT_ACCESS_TOKEN_TTL;
|
|
} else {
|
|
accessTokenTtl = DEFAULT_ACCESS_TOKEN_TTL_NO_REFRESH;
|
|
}
|
|
const refreshTokenTtl = upstreamTokens.refreshToken ? upstreamTokens.refreshExpiresIn ?? this.config.refreshTokenTtl ?? DEFAULT_REFRESH_TOKEN_TTL : 0;
|
|
const upstreamStorageTtl = Math.max(accessTokenTtl, refreshTokenTtl, 1);
|
|
const upstreamTokenKey = this.generateId();
|
|
await this.tokenStorage.save(
|
|
`upstream:${upstreamTokenKey}`,
|
|
upstreamTokens,
|
|
upstreamStorageTtl
|
|
);
|
|
const accessToken = this.jwtIssuer.issueAccessToken(
|
|
clientId,
|
|
upstreamTokens.scope,
|
|
customClaims || void 0,
|
|
accessTokenTtl
|
|
);
|
|
const accessJti = await this.extractJti(accessToken);
|
|
await this.tokenStorage.save(
|
|
`mapping:${accessJti}`,
|
|
{
|
|
clientId,
|
|
createdAt: /* @__PURE__ */ new Date(),
|
|
expiresAt: new Date(Date.now() + accessTokenTtl * 1e3),
|
|
jti: accessJti,
|
|
scope: upstreamTokens.scope,
|
|
upstreamTokenKey
|
|
},
|
|
accessTokenTtl
|
|
);
|
|
const response = {
|
|
access_token: accessToken,
|
|
expires_in: accessTokenTtl,
|
|
scope: upstreamTokens.scope.join(" "),
|
|
token_type: "Bearer"
|
|
};
|
|
if (upstreamTokens.refreshToken) {
|
|
const refreshToken = this.jwtIssuer.issueRefreshToken(
|
|
clientId,
|
|
upstreamTokens.scope,
|
|
customClaims || void 0,
|
|
refreshTokenTtl
|
|
);
|
|
const refreshJti = await this.extractJti(refreshToken);
|
|
await this.tokenStorage.save(
|
|
`mapping:${refreshJti}`,
|
|
{
|
|
clientId,
|
|
createdAt: /* @__PURE__ */ new Date(),
|
|
expiresAt: new Date(Date.now() + refreshTokenTtl * 1e3),
|
|
jti: refreshJti,
|
|
scope: upstreamTokens.scope,
|
|
upstreamTokenKey
|
|
},
|
|
refreshTokenTtl
|
|
);
|
|
response.refresh_token = refreshToken;
|
|
}
|
|
return response;
|
|
}
|
|
/**
|
|
* Issue swapped tokens for refresh flow
|
|
*/
|
|
async issueSwappedTokensForRefresh(clientId, upstreamTokens, upstreamTokenKey, oldJti) {
|
|
if (!this.jwtIssuer) {
|
|
throw new Error("JWT issuer not initialized");
|
|
}
|
|
await this.tokenStorage.delete(`mapping:${oldJti}`);
|
|
const customClaims = await this.extractUpstreamClaims(upstreamTokens);
|
|
const accessTokenTtl = this.calculateAccessTokenTtl(upstreamTokens);
|
|
const refreshTokenTtl = upstreamTokens.refreshToken ? upstreamTokens.refreshExpiresIn ?? this.config.refreshTokenTtl ?? DEFAULT_REFRESH_TOKEN_TTL : 0;
|
|
const accessToken = this.jwtIssuer.issueAccessToken(
|
|
clientId,
|
|
upstreamTokens.scope,
|
|
customClaims || void 0,
|
|
accessTokenTtl
|
|
);
|
|
const accessJti = await this.extractJti(accessToken);
|
|
await this.tokenStorage.save(
|
|
`mapping:${accessJti}`,
|
|
{
|
|
clientId,
|
|
createdAt: /* @__PURE__ */ new Date(),
|
|
expiresAt: new Date(Date.now() + accessTokenTtl * 1e3),
|
|
jti: accessJti,
|
|
scope: upstreamTokens.scope,
|
|
upstreamTokenKey
|
|
},
|
|
accessTokenTtl
|
|
);
|
|
const response = {
|
|
access_token: accessToken,
|
|
expires_in: accessTokenTtl,
|
|
scope: upstreamTokens.scope.join(" "),
|
|
token_type: "Bearer"
|
|
};
|
|
if (upstreamTokens.refreshToken) {
|
|
const refreshToken = this.jwtIssuer.issueRefreshToken(
|
|
clientId,
|
|
upstreamTokens.scope,
|
|
customClaims || void 0,
|
|
refreshTokenTtl
|
|
);
|
|
const refreshJti = await this.extractJti(refreshToken);
|
|
await this.tokenStorage.save(
|
|
`mapping:${refreshJti}`,
|
|
{
|
|
clientId,
|
|
createdAt: /* @__PURE__ */ new Date(),
|
|
expiresAt: new Date(Date.now() + refreshTokenTtl * 1e3),
|
|
jti: refreshJti,
|
|
scope: upstreamTokens.scope,
|
|
upstreamTokenKey
|
|
},
|
|
refreshTokenTtl
|
|
);
|
|
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);
|
|
}
|
|
/**
|
|
* Parse token response that can be either JSON or URL-encoded
|
|
* GitHub Apps return URL-encoded format, most providers return JSON
|
|
*/
|
|
async parseTokenResponse(response) {
|
|
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
|
const tokenResponseSchema = z.object({
|
|
access_token: z.string().min(1, "access_token cannot be empty"),
|
|
expires_in: z.coerce.number().int().positive().optional(),
|
|
id_token: z.string().optional(),
|
|
refresh_expires_in: z.coerce.number().int().positive().optional(),
|
|
refresh_token: z.string().optional(),
|
|
scope: z.string().optional(),
|
|
token_type: z.string().optional()
|
|
});
|
|
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
const text = await response.text();
|
|
const params = new URLSearchParams(text);
|
|
const rawData = {
|
|
access_token: params.get("access_token") || "",
|
|
expires_in: params.get("expires_in") ? parseInt(params.get("expires_in")) : void 0,
|
|
id_token: params.get("id_token") || void 0,
|
|
refresh_expires_in: params.get("refresh_expires_in") ? parseInt(params.get("refresh_expires_in")) : void 0,
|
|
refresh_token: params.get("refresh_token") || void 0,
|
|
scope: params.get("scope") || void 0,
|
|
token_type: params.get("token_type") || void 0
|
|
};
|
|
return tokenResponseSchema.parse(rawData);
|
|
}
|
|
const rawJson = await response.json();
|
|
return tokenResponseSchema.parse(rawJson);
|
|
}
|
|
/**
|
|
* 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
|
|
});
|
|
}
|
|
/**
|
|
* Refresh upstream tokens with provider
|
|
*/
|
|
async refreshUpstreamTokens(upstreamRefreshToken, requestedScope) {
|
|
const useBasicAuth = this.config.upstreamTokenEndpointAuthMethod === "client_secret_basic";
|
|
const bodyParams = {
|
|
grant_type: "refresh_token",
|
|
refresh_token: upstreamRefreshToken,
|
|
...requestedScope && { scope: requestedScope }
|
|
};
|
|
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) {
|
|
const error = await tokenResponse.json();
|
|
throw new OAuthProxyError(
|
|
error.error || "invalid_grant",
|
|
error.error_description || "Upstream refresh failed"
|
|
);
|
|
}
|
|
const tokens = await this.parseTokenResponse(tokenResponse);
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
expiresIn: tokens.expires_in || 3600,
|
|
idToken: tokens.id_token,
|
|
issuedAt: /* @__PURE__ */ new Date(),
|
|
refreshExpiresIn: tokens.refresh_expires_in,
|
|
refreshToken: tokens.refresh_token || upstreamRefreshToken,
|
|
scope: tokens.scope ? tokens.scope.split(" ") : [],
|
|
tokenType: tokens.token_type || "Bearer"
|
|
};
|
|
}
|
|
/**
|
|
* 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/AuthProvider.ts
|
|
var AuthProvider = class {
|
|
config;
|
|
/**
|
|
* Get the proxy, creating it lazily if needed.
|
|
*/
|
|
get proxy() {
|
|
if (!this._proxy) {
|
|
this._proxy = this.createProxy();
|
|
}
|
|
return this._proxy;
|
|
}
|
|
_proxy;
|
|
constructor(config) {
|
|
this.config = config;
|
|
}
|
|
/**
|
|
* Authenticate function to be used by FastMCP.
|
|
* Extracts Bearer token, validates it, and returns session with upstream access token.
|
|
*/
|
|
async authenticate(request) {
|
|
if (!request) {
|
|
return void 0;
|
|
}
|
|
const authHeader = request.headers?.authorization;
|
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
return void 0;
|
|
}
|
|
const token = authHeader.slice(7);
|
|
const upstreamTokens = await this.proxy.loadUpstreamTokens(token);
|
|
if (!upstreamTokens) {
|
|
return void 0;
|
|
}
|
|
return this.createSession(upstreamTokens);
|
|
}
|
|
/**
|
|
* Get the OAuth configuration object for FastMCP ServerOptions.
|
|
*/
|
|
getOAuthConfig() {
|
|
return {
|
|
authorizationServer: this.proxy.getAuthorizationServerMetadata(),
|
|
enabled: true,
|
|
protectedResource: {
|
|
authorizationServers: [this.config.baseUrl],
|
|
resource: this.config.baseUrl,
|
|
scopesSupported: this.config.scopes ?? this.getDefaultScopes()
|
|
},
|
|
proxy: this.proxy
|
|
};
|
|
}
|
|
/**
|
|
* Get the OAuthProxy instance (for advanced use cases).
|
|
*/
|
|
getProxy() {
|
|
return this.proxy;
|
|
}
|
|
/**
|
|
* Create a session object from upstream tokens.
|
|
* Override in subclasses to add provider-specific session data.
|
|
*/
|
|
createSession(upstreamTokens) {
|
|
return {
|
|
accessToken: upstreamTokens.accessToken,
|
|
expiresAt: upstreamTokens.expiresIn ? Math.floor(Date.now() / 1e3) + upstreamTokens.expiresIn : void 0,
|
|
idToken: upstreamTokens.idToken,
|
|
refreshToken: upstreamTokens.refreshToken,
|
|
scopes: upstreamTokens.scope
|
|
};
|
|
}
|
|
};
|
|
|
|
// src/auth/providers/AzureProvider.ts
|
|
var AzureProvider = class extends AuthProvider {
|
|
tenantId;
|
|
constructor(config) {
|
|
super(config);
|
|
this.tenantId = config.tenantId ?? "common";
|
|
}
|
|
createProxy() {
|
|
return new OAuthProxy({
|
|
allowedRedirectUriPatterns: this.config.allowedRedirectUriPatterns ?? [
|
|
"http://localhost:*",
|
|
"https://*"
|
|
],
|
|
baseUrl: this.config.baseUrl,
|
|
consentRequired: this.config.consentRequired ?? true,
|
|
encryptionKey: this.config.encryptionKey,
|
|
jwtSigningKey: this.config.jwtSigningKey,
|
|
scopes: this.config.scopes ?? this.getDefaultScopes(),
|
|
tokenStorage: this.config.tokenStorage,
|
|
upstreamAuthorizationEndpoint: this.getAuthorizationEndpoint(),
|
|
upstreamClientId: this.config.clientId,
|
|
upstreamClientSecret: this.config.clientSecret,
|
|
upstreamTokenEndpoint: this.getTokenEndpoint()
|
|
});
|
|
}
|
|
getAuthorizationEndpoint() {
|
|
return `https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/authorize`;
|
|
}
|
|
getDefaultScopes() {
|
|
return ["openid", "profile", "email"];
|
|
}
|
|
getTokenEndpoint() {
|
|
return `https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/token`;
|
|
}
|
|
};
|
|
|
|
// src/auth/providers/GitHubProvider.ts
|
|
var GitHubProvider = class extends AuthProvider {
|
|
constructor(config) {
|
|
super(config);
|
|
}
|
|
createProxy() {
|
|
return new OAuthProxy({
|
|
allowedRedirectUriPatterns: this.config.allowedRedirectUriPatterns ?? [
|
|
"http://localhost:*",
|
|
"https://*"
|
|
],
|
|
baseUrl: this.config.baseUrl,
|
|
consentRequired: this.config.consentRequired ?? true,
|
|
encryptionKey: this.config.encryptionKey,
|
|
jwtSigningKey: this.config.jwtSigningKey,
|
|
scopes: this.config.scopes ?? this.getDefaultScopes(),
|
|
tokenStorage: this.config.tokenStorage,
|
|
upstreamAuthorizationEndpoint: this.getAuthorizationEndpoint(),
|
|
upstreamClientId: this.config.clientId,
|
|
upstreamClientSecret: this.config.clientSecret,
|
|
upstreamTokenEndpoint: this.getTokenEndpoint()
|
|
});
|
|
}
|
|
getAuthorizationEndpoint() {
|
|
return "https://github.com/login/oauth/authorize";
|
|
}
|
|
getDefaultScopes() {
|
|
return ["read:user", "user:email"];
|
|
}
|
|
getTokenEndpoint() {
|
|
return "https://github.com/login/oauth/access_token";
|
|
}
|
|
};
|
|
|
|
// src/auth/providers/GoogleProvider.ts
|
|
var GoogleProvider = class extends AuthProvider {
|
|
constructor(config) {
|
|
super(config);
|
|
}
|
|
createProxy() {
|
|
return new OAuthProxy({
|
|
allowedRedirectUriPatterns: this.config.allowedRedirectUriPatterns ?? [
|
|
"http://localhost:*",
|
|
"https://*"
|
|
],
|
|
baseUrl: this.config.baseUrl,
|
|
consentRequired: this.config.consentRequired ?? true,
|
|
encryptionKey: this.config.encryptionKey,
|
|
jwtSigningKey: this.config.jwtSigningKey,
|
|
scopes: this.config.scopes ?? this.getDefaultScopes(),
|
|
tokenStorage: this.config.tokenStorage,
|
|
upstreamAuthorizationEndpoint: this.getAuthorizationEndpoint(),
|
|
upstreamClientId: this.config.clientId,
|
|
upstreamClientSecret: this.config.clientSecret,
|
|
upstreamTokenEndpoint: this.getTokenEndpoint()
|
|
});
|
|
}
|
|
getAuthorizationEndpoint() {
|
|
return "https://accounts.google.com/o/oauth2/v2/auth";
|
|
}
|
|
getDefaultScopes() {
|
|
return ["openid", "profile", "email"];
|
|
}
|
|
getTokenEndpoint() {
|
|
return "https://oauth2.googleapis.com/token";
|
|
}
|
|
};
|
|
|
|
// src/auth/providers/OAuthProvider.ts
|
|
var OAuthProvider = class extends AuthProvider {
|
|
genericConfig;
|
|
constructor(config) {
|
|
super(config);
|
|
this.genericConfig = config;
|
|
}
|
|
createProxy() {
|
|
return new OAuthProxy({
|
|
allowedRedirectUriPatterns: this.config.allowedRedirectUriPatterns ?? [
|
|
"http://localhost:*",
|
|
"https://*"
|
|
],
|
|
baseUrl: this.config.baseUrl,
|
|
consentRequired: this.config.consentRequired ?? true,
|
|
encryptionKey: this.config.encryptionKey,
|
|
jwtSigningKey: this.config.jwtSigningKey,
|
|
scopes: this.config.scopes ?? this.getDefaultScopes(),
|
|
tokenStorage: this.config.tokenStorage,
|
|
upstreamAuthorizationEndpoint: this.getAuthorizationEndpoint(),
|
|
upstreamClientId: this.config.clientId,
|
|
upstreamClientSecret: this.config.clientSecret,
|
|
upstreamTokenEndpoint: this.getTokenEndpoint(),
|
|
upstreamTokenEndpointAuthMethod: this.genericConfig.tokenEndpointAuthMethod ?? "client_secret_basic"
|
|
});
|
|
}
|
|
getAuthorizationEndpoint() {
|
|
return this.genericConfig.authorizationEndpoint;
|
|
}
|
|
getDefaultScopes() {
|
|
return ["openid"];
|
|
}
|
|
getTokenEndpoint() {
|
|
return this.genericConfig.tokenEndpoint;
|
|
}
|
|
};
|
|
|
|
// 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 {
|
|
getAuthSession,
|
|
requireAll,
|
|
requireAny,
|
|
requireAuth,
|
|
requireRole,
|
|
requireScopes,
|
|
DEFAULT_ACCESS_TOKEN_TTL,
|
|
DEFAULT_ACCESS_TOKEN_TTL_NO_REFRESH,
|
|
DEFAULT_REFRESH_TOKEN_TTL,
|
|
DEFAULT_AUTHORIZATION_CODE_TTL,
|
|
DEFAULT_TRANSACTION_TTL,
|
|
ConsentManager,
|
|
JWTIssuer,
|
|
PKCEUtils,
|
|
EncryptedTokenStorage,
|
|
MemoryTokenStorage,
|
|
OAuthProxy,
|
|
OAuthProxyError,
|
|
AuthProvider,
|
|
AzureProvider,
|
|
GitHubProvider,
|
|
GoogleProvider,
|
|
OAuthProvider,
|
|
DiskStore,
|
|
JWKSVerifier
|
|
};
|
|
//# sourceMappingURL=chunk-H4VC4YTC.js.map
|