/**
* JWT validation utilities with JWKS support
*
* Implements OAuth 2.1 / OpenID Connect token validation:
* - JWKS key fetching and caching
* - JWT signature verification
* - Claims validation (iss, aud, exp, nbf)
* - Scope verification
*/
import * as jose from 'jose';
import { createLogger } from './logger.js';
import { AuthenticationError, AuthorizationError } from './errors.js';
const logger = createLogger('jwt');
/**
* JWT validation configuration
*/
export interface JwtConfig {
/** JWKS endpoint URL */
jwksUri: string;
/** Expected token issuer */
issuer: string;
/** Expected audience (resource server identifier) */
audience: string;
/** Clock tolerance in seconds for exp/nbf validation */
clockTolerance?: number;
/** JWKS cache TTL in milliseconds */
jwksCacheTtl?: number;
}
/**
* Validated JWT claims
*/
export interface ValidatedClaims {
/** Subject (user/client identifier) */
sub: string;
/** Issuer */
iss: string;
/** Audience */
aud: string | string[];
/** Expiration time */
exp: number;
/** Issued at time */
iat: number;
/** JWT ID */
jti?: string;
/** Scopes (space-separated or array) */
scope?: string | string[];
/** Client ID (for client credentials flow) */
client_id?: string;
/** All other claims */
[key: string]: unknown;
}
/**
* JWKS cache entry
*/
interface JwksCache {
jwks: jose.JSONWebKeySet;
fetchedAt: number;
}
const DEFAULT_CLOCK_TOLERANCE = 30; // seconds
const DEFAULT_JWKS_CACHE_TTL = 3600_000; // 1 hour
/**
* JWT Validator with JWKS support
*/
export class JwtValidator {
private readonly config: Required<JwtConfig>;
private jwksCache: JwksCache | null = null;
private getKey: jose.GetKeyFunction<jose.JWSHeaderParameters, jose.FlattenedJWSInput> | null = null;
constructor(config: JwtConfig) {
this.config = {
clockTolerance: DEFAULT_CLOCK_TOLERANCE,
jwksCacheTtl: DEFAULT_JWKS_CACHE_TTL,
...config,
};
}
/**
* Validate a JWT and return the verified claims
*/
async validate(token: string): Promise<ValidatedClaims> {
try {
// Get or initialize JWKS
const keyGetter = await this.getKeyGetter();
// Verify the token
const { payload } = await jose.jwtVerify(token, keyGetter, {
issuer: this.config.issuer,
audience: this.config.audience,
clockTolerance: this.config.clockTolerance,
});
// Validate required claims
if (!payload.sub) {
throw new AuthenticationError('Token missing subject claim');
}
logger.debug('JWT validated successfully', {
sub: payload.sub,
iss: payload.iss,
exp: payload.exp,
});
return payload as ValidatedClaims;
} catch (error) {
if (error instanceof jose.errors.JWTExpired) {
logger.warning('JWT expired', { error: error.message });
throw new AuthenticationError('Token expired');
}
if (error instanceof jose.errors.JWTClaimValidationFailed) {
logger.warning('JWT claim validation failed', { error: error.message });
throw new AuthenticationError(`Token validation failed: ${error.message}`);
}
if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
logger.warning('JWT signature verification failed');
throw new AuthenticationError('Invalid token signature');
}
if (error instanceof AuthenticationError) {
throw error;
}
logger.error('JWT validation error', { error: String(error) });
throw new AuthenticationError('Token validation failed');
}
}
/**
* Validate token and check for required scopes
*/
async validateWithScopes(token: string, requiredScopes: string[]): Promise<ValidatedClaims> {
const claims = await this.validate(token);
if (requiredScopes.length === 0) {
return claims;
}
const tokenScopes = this.extractScopes(claims);
const missingScopes = requiredScopes.filter((s) => !tokenScopes.includes(s));
if (missingScopes.length > 0) {
logger.warning('Insufficient scopes', {
required: requiredScopes,
actual: tokenScopes,
missing: missingScopes,
});
throw new AuthorizationError(
`Missing required scopes: ${missingScopes.join(', ')}`,
requiredScopes
);
}
return claims;
}
/**
* Extract scopes from claims (handles both string and array formats)
*/
private extractScopes(claims: ValidatedClaims): string[] {
const scopeClaim = claims.scope;
if (!scopeClaim) {
return [];
}
if (Array.isArray(scopeClaim)) {
return scopeClaim;
}
return scopeClaim.split(' ').filter(Boolean);
}
/**
* Get or create the JWKS key getter
*/
private async getKeyGetter(): Promise<jose.GetKeyFunction<jose.JWSHeaderParameters, jose.FlattenedJWSInput>> {
const now = Date.now();
// Check if cache is valid
if (this.getKey && this.jwksCache) {
const cacheAge = now - this.jwksCache.fetchedAt;
if (cacheAge < this.config.jwksCacheTtl) {
return this.getKey;
}
logger.debug('JWKS cache expired, refreshing');
}
// Fetch JWKS
logger.debug('Fetching JWKS', { uri: this.config.jwksUri });
this.getKey = jose.createRemoteJWKSet(new URL(this.config.jwksUri));
// We can't easily cache the actual JWKS with createRemoteJWKSet,
// but the library handles caching internally
this.jwksCache = {
jwks: { keys: [] }, // Placeholder, actual caching done by jose
fetchedAt: now,
};
return this.getKey;
}
/**
* Clear the JWKS cache (useful for testing or key rotation)
*/
clearCache(): void {
this.jwksCache = null;
this.getKey = null;
logger.debug('JWKS cache cleared');
}
}
/**
* Extract Bearer token from Authorization header
*/
export function extractBearerToken(authHeader: string | undefined): string | null {
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
return authHeader.slice(7).trim() || null;
}
/**
* Create a JWT validator from environment variables
*/
export function createJwtValidatorFromEnv(): JwtValidator | null {
const jwksUri = process.env['MCP_SERVER_JWKS_URI'];
const issuer = process.env['MCP_SERVER_TOKEN_ISSUER'];
const audience = process.env['MCP_SERVER_TOKEN_AUDIENCE'];
if (!jwksUri || !issuer || !audience) {
logger.debug('JWT validation not configured (missing env vars)');
return null;
}
return new JwtValidator({
jwksUri,
issuer,
audience,
clockTolerance: parseInt(process.env['MCP_SERVER_JWT_CLOCK_TOLERANCE'] ?? '30', 10),
});
}