/**
* db-mcp - Token Validator
*
* JWT access token validation using JWKS for signature verification.
* Supports RSA and EC algorithms commonly used with OAuth 2.0.
*/
import * as jose from "jose";
import type {
TokenValidationResult,
TokenClaims,
TokenValidatorConfig,
} from "./types.js";
import {
InvalidTokenError,
TokenExpiredError,
InvalidSignatureError,
JwksFetchError,
} from "./errors.js";
import { parseScopes } from "./scopes.js";
import { createModuleLogger, ERROR_CODES } from "../utils/logger.js";
const logger = createModuleLogger("AUTH");
// =============================================================================
// Token Validator
// =============================================================================
/**
* JWT Token Validator
*
* Validates OAuth 2.0 access tokens using JWKS for signature verification.
*/
export class TokenValidator {
/** Resolved configuration with all defaults applied */
private readonly jwksUri: string;
private readonly issuer: string;
private readonly audience: string;
private readonly clockTolerance: number;
private readonly jwksCacheTtl: number;
private jwks: jose.JWTVerifyGetKey | null = null;
private jwksExpiry = 0;
constructor(config: TokenValidatorConfig) {
this.jwksUri = config.jwksUri;
this.issuer = config.issuer;
this.audience = config.audience;
this.clockTolerance = config.clockTolerance ?? 60;
this.jwksCacheTtl = config.jwksCacheTtl ?? 3600;
logger.info(`Token Validator initialized for issuer: ${this.issuer}`, {
code: "AUTH_INIT",
});
}
/**
* Validate an access token
*
* @param token - The JWT access token
* @returns Validation result with claims or error
*/
async validate(token: string): Promise<TokenValidationResult> {
try {
// Get or refresh JWKS
const jwks = this.getJwks();
// Verify the token
const { payload } = await jose.jwtVerify(token, jwks, {
issuer: this.issuer,
audience: this.audience,
clockTolerance: this.clockTolerance,
});
// Extract and normalize claims
const claims = this.extractClaims(payload);
logger.info(`Token validated for subject: ${claims.sub}`, {
code: "AUTH_TOKEN_VALID",
sub: claims.sub,
scopes: claims.scopes.length,
exp: new Date(claims.exp * 1000).toISOString(),
});
return {
valid: true,
claims,
};
} catch (error) {
return this.handleValidationError(error);
}
}
/**
* Get or refresh the JWKS
*/
private getJwks(): jose.JWTVerifyGetKey {
// Check if JWKS is cached and valid
if (this.jwks && Date.now() < this.jwksExpiry) {
return this.jwks;
}
logger.info(`Fetching JWKS from: ${this.jwksUri}`, {
code: "AUTH_JWKS_FETCH",
});
try {
// Create JWKS remote key set
this.jwks = jose.createRemoteJWKSet(new URL(this.jwksUri), {
cooldownDuration: 30000, // 30 seconds between retries
cacheMaxAge: this.jwksCacheTtl * 1000,
});
this.jwksExpiry = Date.now() + this.jwksCacheTtl * 1000;
logger.info(`JWKS cached for ${String(this.jwksCacheTtl)}s`, {
code: "AUTH_JWKS_CACHED",
});
return this.jwks;
} catch (error) {
const cause = error instanceof Error ? error : new Error(String(error));
logger.error(`Failed to fetch JWKS: ${this.jwksUri}`, {
code: ERROR_CODES.AUTH.JWKS_FETCH_FAILED.full,
error: cause,
});
throw new JwksFetchError(this.jwksUri, cause);
}
}
/**
* Extract and normalize token claims
*/
private extractClaims(payload: jose.JWTPayload): TokenClaims {
// Get scopes from 'scope' claim (space-delimited) or 'scopes' claim (array)
let scopes: string[] = [];
if (typeof payload["scope"] === "string") {
scopes = parseScopes(payload["scope"]);
} else if (Array.isArray(payload["scopes"])) {
scopes = payload["scopes"].filter(
(s): s is string => typeof s === "string",
);
} else if (Array.isArray(payload["scope"])) {
scopes = payload["scope"].filter(
(s): s is string => typeof s === "string",
);
}
return {
sub: payload.sub ?? "unknown",
scopes,
exp: payload.exp ?? 0,
iat: payload.iat ?? 0,
iss: payload.iss,
aud: payload.aud,
nbf: payload.nbf ?? undefined,
jti: payload.jti,
client_id: payload["client_id"] as string | undefined,
// Include all other claims
...payload,
};
}
/**
* Handle validation errors and convert to TokenValidationResult
*/
private handleValidationError(error: unknown): TokenValidationResult {
// Handle jose-specific errors
if (error instanceof jose.errors.JWTExpired) {
logger.warning("Token has expired", {
code: ERROR_CODES.AUTH.TOKEN_EXPIRED.full,
error: error as Error,
});
return {
valid: false,
error: "Token has expired",
errorCode: ERROR_CODES.AUTH.TOKEN_EXPIRED.full,
};
}
if (error instanceof jose.errors.JWTClaimValidationFailed) {
logger.warning(`Token claim validation failed: ${error.message}`, {
code: ERROR_CODES.AUTH.TOKEN_INVALID.full,
error,
});
return {
valid: false,
error: `Token claim validation failed: ${error.message}`,
errorCode: ERROR_CODES.AUTH.TOKEN_INVALID.full,
};
}
if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
logger.warning("Token signature verification failed", {
code: ERROR_CODES.AUTH.SIGNATURE_INVALID.full,
error,
});
return {
valid: false,
error: "Token signature verification failed",
errorCode: ERROR_CODES.AUTH.SIGNATURE_INVALID.full,
};
}
if (error instanceof jose.errors.JWKSNoMatchingKey) {
logger.warning("No matching key found in JWKS", {
code: ERROR_CODES.AUTH.TOKEN_INVALID.full,
error,
});
return {
valid: false,
error: "No matching key found in JWKS",
errorCode: ERROR_CODES.AUTH.TOKEN_INVALID.full,
};
}
// Handle other errors
const message = error instanceof Error ? error.message : String(error);
logger.error(`Token validation failed: ${message}`, {
code: ERROR_CODES.AUTH.TOKEN_INVALID.full,
error: error instanceof Error ? error : undefined,
});
return {
valid: false,
error: `Token validation failed: ${message}`,
errorCode: ERROR_CODES.AUTH.TOKEN_INVALID.full,
};
}
/**
* Refresh the JWKS cache
*/
refreshJwks(): void {
this.jwks = null;
this.jwksExpiry = 0;
this.getJwks();
logger.info("JWKS cache refreshed", { code: "AUTH_JWKS_REFRESHED" });
}
/**
* Clear the JWKS cache
*/
clearCache(): void {
this.jwks = null;
this.jwksExpiry = 0;
logger.info("Token validator cache cleared", {
code: "AUTH_CACHE_CLEARED",
});
}
/**
* Convert a validation error to the appropriate OAuth error class
*/
static toOAuthError(
result: TokenValidationResult,
): InvalidTokenError | TokenExpiredError | InvalidSignatureError {
if (result.errorCode === ERROR_CODES.AUTH.TOKEN_EXPIRED.full) {
return new TokenExpiredError();
}
if (result.errorCode === ERROR_CODES.AUTH.SIGNATURE_INVALID.full) {
return new InvalidSignatureError();
}
return new InvalidTokenError(result.error);
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a Token Validator instance
*/
export function createTokenValidator(
config: TokenValidatorConfig,
): TokenValidator {
return new TokenValidator(config);
}