/**
* postgres-mcp - Token Validator
*
* JWT token validation with JWKS support.
*/
import * as jose from "jose";
import type {
TokenValidatorConfig,
TokenValidationResult,
TokenClaims,
} from "./types.js";
import { JwksFetchError } from "./errors.js";
import { parseScopes } from "./scopes.js";
import { logger } from "../utils/logger.js";
/**
* JWT Token Validator with JWKS support
*/
export class TokenValidator {
private readonly config: TokenValidatorConfig;
private jwksCache: jose.JWTVerifyGetKey | null = null;
private jwksCacheTime = 0;
constructor(config: TokenValidatorConfig) {
this.config = {
...config,
clockTolerance: config.clockTolerance ?? 60,
jwksCacheTtl: config.jwksCacheTtl ?? 3600,
algorithms: config.algorithms ?? [
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
],
};
}
/**
* Validate a JWT token
*/
async validate(token: string): Promise<TokenValidationResult> {
try {
// Get or refresh JWKS
const jwks = this.getJWKS();
// Build verification options
const verifyOptions: jose.JWTVerifyOptions = {
issuer: this.config.issuer,
audience: this.config.audience,
};
if (this.config.clockTolerance !== undefined) {
verifyOptions.clockTolerance = this.config.clockTolerance;
}
// Verify the token
const { payload } = await jose.jwtVerify(token, jwks, verifyOptions);
// Extract claims
const claims: TokenClaims = {
sub: payload.sub ?? "",
scopes: parseScopes(payload["scope"] as string | undefined),
exp: payload.exp ?? 0,
iat: payload.iat ?? 0,
iss: payload.iss,
aud: payload.aud,
nbf: payload.nbf,
jti: payload.jti,
client_id: payload["client_id"] as string | undefined,
};
logger.debug("Token validated successfully", { sub: claims.sub });
return { valid: true, claims };
} catch (error) {
return this.handleValidationError(error);
}
}
/**
* Get or refresh JWKS cache
*/
private getJWKS(): jose.JWTVerifyGetKey {
const now = Date.now();
const cacheTtlMs = (this.config.jwksCacheTtl ?? 3600) * 1000;
// Check if cache is still valid
if (this.jwksCache && now - this.jwksCacheTime < cacheTtlMs) {
return this.jwksCache;
}
try {
// Create new JWKS remote key set
this.jwksCache = jose.createRemoteJWKSet(new URL(this.config.jwksUri));
this.jwksCacheTime = now;
logger.debug("JWKS cache refreshed", { uri: this.config.jwksUri });
return this.jwksCache;
} catch (error) {
logger.error("Failed to fetch JWKS", {
uri: this.config.jwksUri,
error: String(error),
});
throw new JwksFetchError(
`Failed to fetch JWKS from ${this.config.jwksUri}`,
);
}
}
/**
* Handle validation errors
*/
private handleValidationError(error: unknown): TokenValidationResult {
if (error instanceof jose.errors.JWTExpired) {
return {
valid: false,
error: "Token has expired",
errorCode: "TOKEN_EXPIRED",
};
}
if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
return {
valid: false,
error: "Invalid token signature",
errorCode: "INVALID_SIGNATURE",
};
}
if (error instanceof jose.errors.JWTClaimValidationFailed) {
return {
valid: false,
error: `Claim validation failed: ${error.message}`,
errorCode: "INVALID_CLAIMS",
};
}
// Generic error
return {
valid: false,
error: error instanceof Error ? error.message : "Token validation failed",
errorCode: "INVALID_TOKEN",
};
}
/**
* Invalidate JWKS cache (for testing or forced refresh)
*/
invalidateCache(): void {
this.jwksCache = null;
this.jwksCacheTime = 0;
}
}
/**
* Create a token validator instance
*/
export function createTokenValidator(
config: TokenValidatorConfig,
): TokenValidator {
return new TokenValidator(config);
}