mcp-framework
Version:
Framework for building Model Context Protocol (MCP) servers in Typescript
151 lines (150 loc) • 5.66 kB
JavaScript
import crypto from 'crypto';
import { logger } from '../../core/Logger.js';
export class IntrospectionValidator {
config;
cache;
constructor(config) {
this.config = {
cacheTTL: config.cacheTTL || 300000,
...config,
};
this.cache = new Map();
logger.debug(`IntrospectionValidator initialized with endpoint: ${this.config.endpoint}, cacheTTL: ${this.config.cacheTTL}ms`);
}
async validate(token) {
try {
logger.debug('Starting token introspection');
const cached = this.getCachedIntrospection(token);
if (cached) {
logger.debug('Using cached introspection result');
return this.convertToClaims(cached);
}
const response = await this.introspectToken(token);
if (!response.active) {
logger.warn('Token is inactive');
throw new Error('Token is inactive');
}
this.cacheIntrospection(token, response);
const claims = this.convertToClaims(response);
logger.debug('Token introspection successful');
return claims;
}
catch (error) {
if (error instanceof Error) {
logger.error(`Token introspection failed: ${error.message}`);
throw error;
}
throw new Error('Token introspection failed: Unknown error');
}
}
async introspectToken(token) {
try {
logger.debug('Calling introspection endpoint');
const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
const response = await fetch(this.config.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${credentials}`,
},
body: new URLSearchParams({ token }),
});
if (!response.ok) {
throw new Error(`Introspection endpoint returned ${response.status}: ${response.statusText}`);
}
const data = (await response.json());
if (typeof data.active !== 'boolean') {
throw new Error('Invalid introspection response: missing active field');
}
logger.debug(`Introspection response received - active: ${data.active}, sub: ${data.sub || 'N/A'}`);
return data;
}
catch (error) {
if (error instanceof Error) {
logger.error(`Introspection request failed: ${error.message}`);
throw new Error(`Introspection request failed: ${error.message}`);
}
throw new Error('Introspection request failed: Unknown error');
}
}
getCachedIntrospection(token) {
const tokenHash = this.hashToken(token);
const cached = this.cache.get(tokenHash);
if (!cached) {
return null;
}
const age = Date.now() - cached.timestamp;
if (age > this.config.cacheTTL) {
logger.debug('Cached introspection expired, removing from cache');
this.cache.delete(tokenHash);
return null;
}
if (cached.response.exp) {
const now = Math.floor(Date.now() / 1000);
if (now >= cached.response.exp) {
logger.debug('Cached token expired, removing from cache');
this.cache.delete(tokenHash);
return null;
}
}
return cached.response;
}
cacheIntrospection(token, response) {
const tokenHash = this.hashToken(token);
this.cache.set(tokenHash, {
response,
timestamp: Date.now(),
});
this.cleanupCache();
logger.debug('Introspection result cached');
}
hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
cleanupCache() {
const now = Date.now();
for (const [tokenHash, cached] of this.cache.entries()) {
const age = now - cached.timestamp;
if (age > this.config.cacheTTL) {
this.cache.delete(tokenHash);
}
else if (cached.response.exp) {
const nowSec = Math.floor(now / 1000);
if (nowSec >= cached.response.exp) {
this.cache.delete(tokenHash);
}
}
}
}
convertToClaims(response) {
if (!response.sub) {
throw new Error('Introspection response missing required field: sub');
}
if (!response.iss) {
throw new Error('Introspection response missing required field: iss');
}
if (!response.aud) {
throw new Error('Introspection response missing required field: aud');
}
if (!response.exp) {
throw new Error('Introspection response missing required field: exp');
}
const now = Math.floor(Date.now() / 1000);
if (now >= response.exp) {
throw new Error('Token has expired');
}
if (response.nbf && now < response.nbf) {
throw new Error('Token not yet valid (nbf claim)');
}
return {
sub: response.sub,
iss: response.iss,
aud: response.aud,
exp: response.exp,
nbf: response.nbf,
iat: response.iat,
scope: response.scope,
...response,
};
}
}