Skip to main content
Glama
JwtAuth.php19 kB
<?php // phpcs:ignore /** * JWT Authentication implementation. * * @package WordPress_MCP * @subpackage Auth */ declare(strict_types=1); namespace Automattic\WordpressMcp\Auth; use Automattic\WordpressMcp\Core\McpErrorHandler; use Firebase\JWT\JWT; use Firebase\JWT\Key; use WP_Error; use WP_REST_Request; use WP_REST_Response; use Exception; /** * Class JwtAuth * * Handles JWT authentication for WordPress REST API. */ class JwtAuth { /** * Option name for storing JWT secret key. * * @var string */ private const JWT_SECRET_KEY_OPTION = 'wpmcp_jwt_secret_key'; /** * Default access token expiration time in seconds. * * @var int */ private const JWT_ACCESS_EXP_DEFAULT = 3600; // 1 hour. /** * Minimum access token expiration time in seconds. * * @var int */ private const JWT_ACCESS_EXP_MIN = 3600; // 1 hour. /** * Default maximum access token expiration time in seconds. * * @var int */ private const JWT_ACCESS_EXP_MAX_DEFAULT = 2592000; // 30 days. /** * Option name for storing active tokens. * * @var string */ private const TOKEN_REGISTRY_OPTION = 'jwt_token_registry'; /** * MCP endpoint path pattern for authentication. * * @var string */ private const MCP_ENDPOINT_PATTERN = '/wp/v2/wpmcp'; /** * Basic authentication pattern. * * @var string */ private const BASIC_AUTH_PATTERN = '/^Basic\s/'; /** * Bearer token pattern. * * @var string */ private const BEARER_TOKEN_PATTERN = '/Bearer\s(\S+)/'; /** * Get JWT secret key from options or generate a new one if not exists. * * @return string */ private function get_jwt_secret_key(): string { $key = get_option( self::JWT_SECRET_KEY_OPTION ); if ( empty( $key ) ) { // Generate a new random key if none exists. $key = wp_generate_password( 64, true, true ); update_option( self::JWT_SECRET_KEY_OPTION, $key ); } return $key; } /** * Get the maximum allowed expiration time for JWT tokens. * * @return int Maximum expiration time in seconds. */ private function get_max_expiration_time(): int { /** * Filter the maximum JWT token expiration time. * * @since 1.0.0 * * @param int $max_expiration Maximum expiration time in seconds. Default 30 days. */ return (int) apply_filters( 'wpmcp_jwt_max_expiration_time', self::JWT_ACCESS_EXP_MAX_DEFAULT ); } /** * Initialize the JWT authentication. */ public function __construct() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_filter( 'rest_authentication_errors', array( $this, 'authenticate_request' ) ); // Also hook into MCP-specific authentication filter. add_filter( 'wpmcp_authenticate_request', array( $this, 'authenticate_mcp_request' ), 5, 2 ); } /** * Register REST API routes for JWT authentication. */ public function register_routes(): void { $max_expiration = $this->get_max_expiration_time(); register_rest_route( 'jwt-auth/v1', '/token', array( 'methods' => 'POST', 'callback' => array( $this, 'generate_jwt_token' ), 'permission_callback' => '__return_true', 'args' => array( 'username' => array( 'type' => 'string', 'description' => 'Username for authentication', 'required' => false, ), 'password' => array( 'type' => 'string', 'description' => 'Password for authentication', 'required' => false, ), 'expires_in' => array( 'type' => 'integer', 'description' => sprintf( 'Token expiration time in seconds (%d-%d)', self::JWT_ACCESS_EXP_MIN, $max_expiration ), 'required' => false, 'minimum' => self::JWT_ACCESS_EXP_MIN, 'maximum' => $max_expiration, 'default' => self::JWT_ACCESS_EXP_DEFAULT, ), ), ) ); register_rest_route( 'jwt-auth/v1', '/revoke', array( 'methods' => 'POST', 'callback' => array( $this, 'revoke_token' ), 'permission_callback' => array( $this, 'check_revoke_permission' ), ) ); register_rest_route( 'jwt-auth/v1', '/tokens', array( 'methods' => 'GET', 'callback' => array( $this, 'list_tokens' ), 'permission_callback' => array( $this, 'check_revoke_permission' ), ) ); } /** * Check if the current user has permission to manage tokens. * * @return bool */ public function check_revoke_permission(): bool { return current_user_can( 'manage_options' ); } /** * Generate JWT token for authenticated user. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error */ public function generate_jwt_token( WP_REST_Request $request ) { $params = $request->get_json_params(); $expires_in = isset( $params['expires_in'] ) ? intval( $params['expires_in'] ) : self::JWT_ACCESS_EXP_DEFAULT; $max_expiration = $this->get_max_expiration_time(); // Validate expiration time. if ( $expires_in < self::JWT_ACCESS_EXP_MIN || $expires_in > $max_expiration ) { $max_days = floor( $max_expiration / 86400 ); return new WP_Error( 'invalid_expiration', sprintf( 'Token expiration must be between %d seconds (1 hour) and %d seconds (%d days)', self::JWT_ACCESS_EXP_MIN, $max_expiration, $max_days ), array( 'status' => 400 ) ); } // If user is already authenticated, use their ID. if ( is_user_logged_in() ) { return rest_ensure_response( $this->generate_token( get_current_user_id(), $expires_in ) ); } // Otherwise, try to authenticate with provided credentials. $username = isset( $params['username'] ) ? sanitize_text_field( $params['username'] ) : ''; $password = isset( $params['password'] ) ? $params['password'] : ''; $user = wp_authenticate( $username, $password ); if ( is_wp_error( $user ) ) { return new WP_Error( 'invalid_credentials', 'Invalid username or password', array( 'status' => 403 ) ); } return rest_ensure_response( $this->generate_token( $user->ID, $expires_in ) ); } /** * Generate access token. * * @param int $user_id The user ID. * @param int $expires_in Token expiration time in seconds. * @return array */ private function generate_token( int $user_id, int $expires_in = self::JWT_ACCESS_EXP_DEFAULT ): array { $issued_at = time(); $expires_at = $issued_at + $expires_in; $jti = wp_generate_password( 32, false ); $payload = array( 'iss' => get_bloginfo( 'url' ), 'iat' => $issued_at, 'exp' => $expires_at, 'user_id' => $user_id, 'jti' => $jti, ); $token = JWT::encode( $payload, $this->get_jwt_secret_key(), 'HS256' ); // Register the token. $this->register_token( $jti, $user_id, $issued_at, $expires_at ); return array( 'token' => $token, 'user_id' => $user_id, 'expires_in' => $expires_in, 'expires_at' => $expires_at, ); } /** * Register a new token in the registry. * * @param string $jti Token ID. * @param int $user_id User ID. * @param int $issued_at Token issued timestamp. * @param int $expires_at Token expiration timestamp. */ private function register_token( string $jti, int $user_id, int $issued_at, int $expires_at ): void { $registry = get_option( self::TOKEN_REGISTRY_OPTION, array() ); $registry[ $jti ] = array( 'user_id' => $user_id, 'issued_at' => $issued_at, 'expires_at' => $expires_at, 'revoked' => false, ); update_option( self::TOKEN_REGISTRY_OPTION, $registry ); } /** * Revoke a JWT token. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error */ public function revoke_token( WP_REST_Request $request ) { $params = $request->get_json_params(); $jti = isset( $params['jti'] ) ? $params['jti'] : ''; if ( empty( $jti ) ) { return new WP_Error( 'missing_jti', 'Token ID is required.', array( 'status' => 400 ) ); } $registry = get_option( self::TOKEN_REGISTRY_OPTION, array() ); if ( ! isset( $registry[ $jti ] ) ) { return new WP_Error( 'token_not_found', 'Token not found in registry.', array( 'status' => 404 ) ); } $registry[ $jti ]['revoked'] = true; update_option( self::TOKEN_REGISTRY_OPTION, $registry ); return rest_ensure_response( array( 'message' => 'Token revoked successfully.', ) ); } /** * List all active tokens. * * @return WP_REST_Response|WP_Error */ public function list_tokens() { $registry = get_option( self::TOKEN_REGISTRY_OPTION, array() ); $tokens = array(); $current_time = time(); $has_changes = false; foreach ( $registry as $jti => $token_data ) { // Skip and remove expired tokens. if ( $current_time > $token_data['expires_at'] ) { unset( $registry[ $jti ] ); $has_changes = true; continue; } $user = get_user_by( 'id', $token_data['user_id'] ); if ( ! $user ) { unset( $registry[ $jti ] ); $has_changes = true; continue; } $tokens[] = array( 'jti' => $jti, 'user' => array( 'id' => $user->ID, 'username' => $user->user_login, 'display_name' => $user->display_name, ), 'issued_at' => $token_data['issued_at'], 'expires_at' => $token_data['expires_at'], 'revoked' => $token_data['revoked'], 'is_expired' => false, ); } // Update the registry if we removed any tokens. if ( $has_changes ) { update_option( self::TOKEN_REGISTRY_OPTION, $registry ); } $response_data = array( 'tokens' => $tokens, 'max_expiration' => $this->get_max_expiration_time(), 'max_days' => floor( $this->get_max_expiration_time() / 86400 ), ); return rest_ensure_response( $response_data ); } /** * Check if a token is valid. * * @param string $jti The token ID. * @return bool */ private function is_token_valid( string $jti ): bool { $registry = get_option( self::TOKEN_REGISTRY_OPTION, array() ); if ( ! isset( $registry[ $jti ] ) ) { return false; } $token_data = $registry[ $jti ]; // Check if token is revoked or expired. if ( $token_data['revoked'] || time() > $token_data['expires_at'] ) { return false; } return true; } /** * Check if the current request is for an MCP endpoint. * * @return bool */ private function is_mcp_endpoint(): bool { $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; return str_contains( $request_uri, self::MCP_ENDPOINT_PATTERN ); } /** * Get Authorization header from request. * * @return string */ private function get_authorization_header(): string { return isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ) : ''; } /** * Check if the authorization header contains Basic authentication. * * @param string $auth Authorization header value. * @return bool */ private function is_basic_auth( string $auth ): bool { return ! empty( $auth ) && preg_match( self::BASIC_AUTH_PATTERN, $auth ); } /** * Extract Bearer token from authorization header. * * @param string $auth Authorization header value. * @return string|null Token if found, null otherwise. */ private function extract_bearer_token( string $auth ): ?string { if ( preg_match( self::BEARER_TOKEN_PATTERN, $auth, $matches ) ) { return $matches[1]; } return null; } /** * Check if cookie-based authentication is valid for MCP endpoints. * * @return bool */ private function is_valid_cookie_auth(): bool { // Only allow cookie auth for logged-in users with manage_options capability. // This provides a secure fallback for admin users. return is_user_logged_in() && current_user_can( 'manage_options' ); } /** * Log authentication events for security monitoring. * * @param string $event Event type. * @param string $details Event details. */ private function log_auth_event( string $event, string $details ): void { // Only log if WP_DEBUG is enabled to avoid filling logs in production. if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // Use error_log for better performance than custom logging. $log_message = sprintf( '[WPMCP JWT Auth] %s: %s (IP: %s, URI: %s)', $event, $details, isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown', isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : 'unknown' ); McpErrorHandler::log_error( $log_message ); } } /** * Authenticate REST API request using JWT token. * * @param mixed $result The authentication result. * @return mixed * @throws Exception When token validation fails. */ public function authenticate_request( $result ) { // If already authenticated, return early. if ( ! empty( $result ) ) { return $result; } // Only apply JWT authentication to MCP endpoints. if ( ! $this->is_mcp_endpoint() ) { return $result; } $auth = $this->get_authorization_header(); // Handle Basic authentication - let it pass through to WordPress core handlers. if ( $this->is_basic_auth( $auth ) ) { $this->log_auth_event( 'BASIC_AUTH_DETECTED', 'Deferring to Basic auth handler' ); return $result; } // Handle missing Authorization header. if ( empty( $auth ) ) { return $this->handle_missing_authorization(); } // Handle Bearer token authentication. return $this->handle_bearer_token( $auth ); } /** * Handle authentication when no Authorization header is present. * * @return mixed Authentication result. */ private function handle_missing_authorization() { // Fallback to cookie authentication for admin users. if ( $this->is_valid_cookie_auth() ) { $this->log_auth_event( 'COOKIE_AUTH_SUCCESS', 'Admin user authenticated via cookies' ); return true; } $this->log_auth_event( 'AUTH_REQUIRED', 'No valid authentication method found' ); return new WP_Error( 'unauthorized', 'Authentication required. Please provide a Bearer token or log in as an administrator.', array( 'status' => 401 ) ); } /** * Handle Bearer token authentication. * * @param string $auth Authorization header value. * @return mixed Authentication result. */ private function handle_bearer_token( string $auth ) { $token = $this->extract_bearer_token( $auth ); if ( null === $token ) { $this->log_auth_event( 'INVALID_AUTH_FORMAT', 'Authorization header present but not Bearer token' ); return new WP_Error( 'unauthorized', 'Invalid Authorization header format. Expected "Bearer <token>".', array( 'status' => 401 ) ); } return $this->validate_jwt_token( $token ); } /** * Validate JWT token and authenticate user. * * @param string $token JWT token. * @return mixed Authentication result. */ private function validate_jwt_token( string $token ) { // Skip OAuth tokens (they typically start with specific prefixes). if ( str_starts_with( $token, 'access_' ) || str_starts_with( $token, 'oauth_' ) ) { // Not a JWT token, let OAuth handle it. return null; } // Check if it looks like a JWT token (has dots for header.payload.signature). if ( ! str_contains( $token, '.' ) ) { // Not a JWT format, return error for invalid token. $this->log_auth_event( 'JWT_FORMAT_INVALID', 'Token does not match JWT format' ); return new WP_Error( 'invalid_token', 'Token format is invalid.', array( 'status' => 403 ) ); } try { $decoded = JWT::decode( $token, new Key( $this->get_jwt_secret_key(), 'HS256' ) ); // Validate token ID. if ( ! isset( $decoded->jti ) || ! $this->is_token_valid( $decoded->jti ) ) { $this->log_auth_event( 'TOKEN_INVALID', 'Token is invalid, expired, or revoked' ); return new WP_Error( 'token_invalid', 'Token is invalid, expired, or has been revoked.', array( 'status' => 401 ) ); } // Validate user. if ( ! isset( $decoded->user_id ) ) { $this->log_auth_event( 'TOKEN_MALFORMED', 'Token missing user_id claim' ); return new WP_Error( 'invalid_token', 'Token is malformed: missing user_id.', array( 'status' => 403 ) ); } $user = get_user_by( 'id', $decoded->user_id ); if ( ! $user ) { $this->log_auth_event( 'USER_NOT_FOUND', "User ID {$decoded->user_id} not found" ); return new WP_Error( 'invalid_token', 'User associated with token no longer exists.', array( 'status' => 403 ) ); } // Set current user. wp_set_current_user( $user->ID ); $this->log_auth_event( 'JWT_AUTH_SUCCESS', "User {$user->user_login} authenticated via JWT" ); return true; } catch ( Exception $e ) { $this->log_auth_event( 'JWT_DECODE_ERROR', $e->getMessage() ); return new WP_Error( 'invalid_token', 'Token validation failed: ' . $e->getMessage(), array( 'status' => 403 ) ); } } /** * Authenticate MCP-specific requests. * * @param mixed $result Current authentication result. * @param WP_REST_Request|null $request The request object. * @return mixed Authentication result. */ public function authenticate_mcp_request( $result, ?WP_REST_Request $request ) { // If already authenticated, return early. if ( ! empty( $result ) ) { return $result; } // If no request object, we can't authenticate via JWT. if ( null === $request ) { return $result; } $auth = $request->get_header( 'authorization' ); if ( empty( $auth ) ) { return $result; } // Extract Bearer token. if ( ! preg_match( '/Bearer\s+(.+)/i', $auth, $matches ) ) { return $result; } $token = $matches[1]; // Skip OAuth tokens (they typically start with specific prefixes). if ( str_starts_with( $token, 'access_' ) || str_starts_with( $token, 'oauth_' ) ) { // Not a JWT token, let OAuth handle it. return $result; } // Check if it looks like a JWT token (has dots for header.payload.signature). if ( ! str_contains( $token, '.' ) ) { // Not a JWT format, but we're in MCP context so we should try to validate. return $result; } // Try to validate as JWT. try { $decoded = JWT::decode( $token, new Key( $this->get_jwt_secret_key(), 'HS256' ) ); // Validate token ID. if ( ! isset( $decoded->jti ) || ! $this->is_token_valid( $decoded->jti ) ) { return $result; } // Validate user. if ( ! isset( $decoded->user_id ) ) { return $result; } $user = get_user_by( 'id', $decoded->user_id ); if ( ! $user ) { return $result; } // Set current user. wp_set_current_user( $user->ID ); return true; } catch ( Exception $e ) { // Not a valid JWT, let other authenticators try. return $result; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Automattic/wordpress-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server