| 1 |
<?php |
| 2 |
/** |
| 3 |
* MCP OAuth HTTP Transport. |
| 4 |
* |
| 5 |
* Custom transport that validates a JWT Bearer token before delegating to the |
| 6 |
* standard MCP HttpRequestHandler. Auth is performed inside handle_request() |
| 7 |
* (rather than check_permission()) so we can return a proper 401 response with |
| 8 |
* a WWW-Authenticate challenge header — something WordPress's permission-callback |
| 9 |
* mechanism cannot produce. |
| 10 |
*/ |
| 11 |
|
| 12 |
declare( strict_types=1 ); |
| 13 |
|
| 14 |
namespace WPMedia\MCP\OAuth\Transport; |
| 15 |
|
| 16 |
use WP\MCP\Transport\Contracts\McpRestTransportInterface; |
| 17 |
use WP\MCP\Transport\Infrastructure\HttpRequestContext; |
| 18 |
use WP\MCP\Transport\Infrastructure\HttpRequestHandler; |
| 19 |
use WP\MCP\Transport\Infrastructure\McpTransportContext; |
| 20 |
use WP\MCP\Transport\Infrastructure\McpTransportHelperTrait; |
| 21 |
use WPMedia\MCP\OAuth\Auth\JWT; |
| 22 |
use WPMedia\MCP\OAuth\Auth\SecretManager; |
| 23 |
use WPMedia\MCP\OAuth\Logging\McpLogger; |
| 24 |
|
| 25 |
/** |
| 26 |
* OAuth HTTP Transport for the MCP OAuth server. |
| 27 |
*/ |
| 28 |
class OAuthHttpTransport implements McpRestTransportInterface { |
| 29 |
use McpTransportHelperTrait; |
| 30 |
|
| 31 |
/** |
| 32 |
* Delegates all MCP protocol handling (sessions, JSON-RPC, etc.). |
| 33 |
* |
| 34 |
* @var HttpRequestHandler |
| 35 |
*/ |
| 36 |
protected HttpRequestHandler $request_handler; |
| 37 |
|
| 38 |
/** |
| 39 |
* Constructor — registers the REST route on rest_api_init. |
| 40 |
* |
| 41 |
* @param McpTransportContext $transport_context Transport context provided by the MCP adapter. |
| 42 |
*/ |
| 43 |
public function __construct( McpTransportContext $transport_context ) { |
| 44 |
$this->request_handler = new HttpRequestHandler( $transport_context ); |
| 45 |
add_action( 'rest_api_init', [ $this, 'register_routes' ], 16 ); |
| 46 |
} |
| 47 |
|
| 48 |
/** |
| 49 |
* Register the MCP OAuth REST route. |
| 50 |
* |
| 51 |
* @return void |
| 52 |
*/ |
| 53 |
public function register_routes(): void { |
| 54 |
$server = $this->request_handler->transport_context->mcp_server; |
| 55 |
|
| 56 |
register_rest_route( |
| 57 |
$server->get_server_route_namespace(), |
| 58 |
$server->get_server_route(), |
| 59 |
[ |
| 60 |
'methods' => [ 'POST', 'GET', 'DELETE' ], |
| 61 |
'callback' => [ $this, 'handle_request' ], |
| 62 |
'permission_callback' => [ $this, 'check_permission' ], |
| 63 |
] |
| 64 |
); |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Permission callback — always returns true. |
| 69 |
* |
| 70 |
* Authentication is performed in handle_request() so we can return a 401 |
| 71 |
* response with the required WWW-Authenticate header. WordPress's built-in |
| 72 |
* permission-callback mechanism only allows bool|\WP_Error returns and |
| 73 |
* cannot set custom response headers. |
| 74 |
* |
| 75 |
* @param \WP_REST_Request $request Incoming request. |
| 76 |
* @return true |
| 77 |
*/ |
| 78 |
public function check_permission( \WP_REST_Request $request ): bool { |
| 79 |
return true; |
| 80 |
} |
| 81 |
|
| 82 |
/** |
| 83 |
* Handle an incoming MCP request. |
| 84 |
* |
| 85 |
* Validates the JWT Bearer token first. On failure, returns a 401 response |
| 86 |
* with a WWW-Authenticate challenge so OAuth 2.1 clients know how to |
| 87 |
* authenticate. On success, establishes the WordPress user context and |
| 88 |
* delegates to HttpRequestHandler for full MCP protocol compliance. |
| 89 |
* |
| 90 |
* @param \WP_REST_Request $request Incoming REST request. |
| 91 |
* @return \WP_REST_Response MCP response. |
| 92 |
*/ |
| 93 |
public function handle_request( \WP_REST_Request $request ): \WP_REST_Response { |
| 94 |
$user_or_error = $this->validate_bearer_token( $request ); |
| 95 |
|
| 96 |
if ( is_wp_error( $user_or_error ) ) { |
| 97 |
return $this->build_auth_error_response( $user_or_error ); |
| 98 |
} |
| 99 |
|
| 100 |
McpLogger::log( |
| 101 |
'TRANSPORT', |
| 102 |
'JWT validated, delegating to MCP handler', |
| 103 |
[ |
| 104 |
'user_id' => $user_or_error->ID, |
| 105 |
'method' => $request->get_method(), |
| 106 |
] |
| 107 |
); |
| 108 |
|
| 109 |
$context = new HttpRequestContext( $request ); |
| 110 |
return $this->request_handler->handle_request( $context ); |
| 111 |
} |
| 112 |
|
| 113 |
/** |
| 114 |
* Validate the JWT Bearer token from the Authorization header. |
| 115 |
* |
| 116 |
* Extracts the token, verifies the JWT signature and expiry, checks audience, |
| 117 |
* confirms the anchoring Application Password has not been revoked, and calls |
| 118 |
* wp_set_current_user() on success. |
| 119 |
* |
| 120 |
* @param \WP_REST_Request $request Incoming REST request. |
| 121 |
* @return \WP_User|\WP_Error Authenticated user, or an error. |
| 122 |
*/ |
| 123 |
private function validate_bearer_token( \WP_REST_Request $request ) { |
| 124 |
$authorization = $request->get_header( 'Authorization' ); |
| 125 |
$route = $request->get_route(); |
| 126 |
$method = $request->get_method(); |
| 127 |
|
| 128 |
McpLogger::log( |
| 129 |
'TRANSPORT', |
| 130 |
'validate_bearer_token called', |
| 131 |
[ |
| 132 |
'method' => $method, |
| 133 |
'route' => $route, |
| 134 |
'headers' => McpLogger::safe_request_headers(), |
| 135 |
'body' => McpLogger::safe_request_body(), |
| 136 |
] |
| 137 |
); |
| 138 |
|
| 139 |
if ( empty( $authorization ) || 0 !== strpos( $authorization, 'Bearer ' ) ) { |
| 140 |
McpLogger::log( |
| 141 |
'TRANSPORT', |
| 142 |
'unauthenticated: no Bearer token', |
| 143 |
[ |
| 144 |
'authorization_header' => $authorization ? substr( $authorization, 0, 20 ) . '...' : '(empty)', |
| 145 |
] |
| 146 |
); |
| 147 |
return $this->unauthenticated_error(); |
| 148 |
} |
| 149 |
|
| 150 |
$token = substr( $authorization, 7 ); |
| 151 |
$secret = SecretManager::get_secret(); |
| 152 |
$claims = JWT::decode( $token, $secret ); |
| 153 |
|
| 154 |
if ( null === $claims ) { |
| 155 |
McpLogger::log( 'TRANSPORT', 'rejected: JWT decode failed (signature invalid or token expired)' ); |
| 156 |
return $this->unauthenticated_error( 'invalid_token', 'JWT signature invalid or token expired.' ); |
| 157 |
} |
| 158 |
|
| 159 |
// Audience must match the MCP OAuth REST endpoint. |
| 160 |
$expected_aud = get_rest_url( null, 'mcp/mcp-oauth-server' ); |
| 161 |
$token_aud = $claims['aud'] ?? ''; |
| 162 |
|
| 163 |
if ( $token_aud !== $expected_aud ) { |
| 164 |
McpLogger::log( |
| 165 |
'TRANSPORT', |
| 166 |
'rejected: JWT audience mismatch', |
| 167 |
[ |
| 168 |
'token_aud' => $token_aud, |
| 169 |
'expected_aud' => $expected_aud, |
| 170 |
] |
| 171 |
); |
| 172 |
return $this->unauthenticated_error( 'invalid_token', 'JWT audience mismatch.' ); |
| 173 |
} |
| 174 |
|
| 175 |
// Issuer must be this site. A staging clone sharing the same JWT secret |
| 176 |
// would otherwise allow cross-site token replay — the audience already |
| 177 |
// embeds the site URL, but verifying iss explicitly keeps this check in |
| 178 |
// step with the refresh-token flow in TokenEndpoint::handle_refresh_token(). |
| 179 |
// home_url() matches what TokenEndpoint mints into iss and the home-based |
| 180 |
// base get_rest_url() uses for aud. |
| 181 |
$expected_iss = home_url(); |
| 182 |
$token_iss = (string) ( $claims['iss'] ?? '' ); |
| 183 |
|
| 184 |
if ( $token_iss !== $expected_iss ) { |
| 185 |
McpLogger::log( |
| 186 |
'TRANSPORT', |
| 187 |
'rejected: JWT issuer mismatch', |
| 188 |
[ |
| 189 |
'token_iss' => $token_iss, |
| 190 |
'expected_iss' => $expected_iss, |
| 191 |
] |
| 192 |
); |
| 193 |
return $this->unauthenticated_error( 'invalid_token', 'JWT issuer mismatch.' ); |
| 194 |
} |
| 195 |
|
| 196 |
$user_id = (int) ( $claims['sub'] ?? 0 ); |
| 197 |
$app_pass_uuid = (string) ( $claims['app_pass_id'] ?? '' ); |
| 198 |
|
| 199 |
if ( 0 === $user_id || '' === $app_pass_uuid ) { |
| 200 |
McpLogger::log( |
| 201 |
'TRANSPORT', |
| 202 |
'rejected: malformed JWT claims', |
| 203 |
[ |
| 204 |
'has_sub' => isset( $claims['sub'] ) ? 'yes' : 'no', |
| 205 |
'has_app_pass_id' => isset( $claims['app_pass_id'] ) ? 'yes' : 'no', |
| 206 |
'claims_keys' => array_keys( $claims ), |
| 207 |
] |
| 208 |
); |
| 209 |
return $this->unauthenticated_error( 'invalid_token', 'Malformed JWT claims.' ); |
| 210 |
} |
| 211 |
|
| 212 |
// Application Password revocation check. |
| 213 |
$app_pass = \WP_Application_Passwords::get_user_application_password( $user_id, $app_pass_uuid ); |
| 214 |
|
| 215 |
if ( ! is_array( $app_pass ) ) { |
| 216 |
McpLogger::log( |
| 217 |
'TRANSPORT', |
| 218 |
'rejected: Application Password revoked or not found', |
| 219 |
[ |
| 220 |
'user_id' => $user_id, |
| 221 |
'app_pass_uuid' => $app_pass_uuid, |
| 222 |
] |
| 223 |
); |
| 224 |
return $this->unauthenticated_error( 'invalid_token', 'MCP session has been revoked.' ); |
| 225 |
} |
| 226 |
|
| 227 |
$user = get_user_by( 'id', $user_id ); |
| 228 |
|
| 229 |
if ( false === $user ) { |
| 230 |
McpLogger::log( 'TRANSPORT', 'rejected: user not found', [ 'user_id' => $user_id ] ); |
| 231 |
return $this->unauthenticated_error( 'invalid_token', 'User not found.' ); |
| 232 |
} |
| 233 |
|
| 234 |
// Set the current user so that get_current_user_id() returns the correct |
| 235 |
// user for any library code that runs after authentication (e.g. |
| 236 |
// HttpSessionValidator::create_session() in wordpress/mcp-adapter). |
| 237 |
wp_set_current_user( $user_id ); |
| 238 |
|
| 239 |
McpLogger::log( |
| 240 |
'TRANSPORT', |
| 241 |
'authentication successful', |
| 242 |
[ |
| 243 |
'user_id' => $user_id, |
| 244 |
'user_login' => $user->user_login, |
| 245 |
'app_pass_uuid' => $app_pass_uuid, |
| 246 |
'token_exp' => $claims['exp'] ?? 'unknown', |
| 247 |
'token_scope' => $claims['scope'] ?? 'unknown', |
| 248 |
] |
| 249 |
); |
| 250 |
|
| 251 |
return $user; |
| 252 |
} |
| 253 |
|
| 254 |
/** |
| 255 |
* Build a WP_Error representing a 401 Unauthorized response. |
| 256 |
* |
| 257 |
* Includes a WWW-Authenticate Bearer challenge so OAuth 2.1 clients know |
| 258 |
* where to find the protected-resource metadata. |
| 259 |
* |
| 260 |
* @param string $code OAuth error code (default 'unauthorized'). |
| 261 |
* @param string $description Human-readable message. |
| 262 |
* @return \WP_Error |
| 263 |
*/ |
| 264 |
private function unauthenticated_error( string $code = 'unauthorized', string $description = '' ): \WP_Error { |
| 265 |
// home_url(): the .well-known document is served via a rewrite rule, so the |
| 266 |
// realm and resource_metadata URL must use the Site Address base. |
| 267 |
$base_url = home_url(); |
| 268 |
|
| 269 |
$www_auth = sprintf( |
| 270 |
'Bearer realm="%s", resource_metadata="%s/.well-known/oauth-protected-resource"', |
| 271 |
esc_url( $base_url ), |
| 272 |
esc_url( $base_url ) |
| 273 |
); |
| 274 |
|
| 275 |
if ( '' !== $description ) { |
| 276 |
$www_auth .= sprintf( ', error="%s", error_description="%s"', $code, $description ); |
| 277 |
} |
| 278 |
|
| 279 |
return new \WP_Error( |
| 280 |
'mcp_unauthorized', |
| 281 |
'' !== $description ? $description : __( 'MCP authentication required.', 'mcp-oauth' ), |
| 282 |
[ |
| 283 |
'status' => 401, |
| 284 |
'WWW-Authenticate' => $www_auth, |
| 285 |
] |
| 286 |
); |
| 287 |
} |
| 288 |
|
| 289 |
/** |
| 290 |
* Build a 401 REST response with a WWW-Authenticate header. |
| 291 |
* |
| 292 |
* @param \WP_Error $error Authentication error. |
| 293 |
* @return \WP_REST_Response |
| 294 |
*/ |
| 295 |
private function build_auth_error_response( \WP_Error $error ): \WP_REST_Response { |
| 296 |
$error_data = $error->get_error_data(); |
| 297 |
$www_auth = is_array( $error_data ) ? ( $error_data['WWW-Authenticate'] ?? '' ) : ''; |
| 298 |
$status = is_array( $error_data ) ? (int) ( $error_data['status'] ?? 401 ) : 401; |
| 299 |
|
| 300 |
$response = new \WP_REST_Response( |
| 301 |
[ |
| 302 |
'code' => $error->get_error_code(), |
| 303 |
'message' => $error->get_error_message(), |
| 304 |
'data' => [ 'status' => $status ], |
| 305 |
], |
| 306 |
$status |
| 307 |
); |
| 308 |
|
| 309 |
if ( '' !== $www_auth ) { |
| 310 |
$response->header( 'WWW-Authenticate', $www_auth ); |
| 311 |
} |
| 312 |
|
| 313 |
McpLogger::log( |
| 314 |
'TRANSPORT', |
| 315 |
'JWT validation failed, returning 401', |
| 316 |
[ |
| 317 |
'error_code' => $error->get_error_code(), |
| 318 |
'has_www_auth' => '' !== $www_auth ? 'yes' : 'no', |
| 319 |
] |
| 320 |
); |
| 321 |
|
| 322 |
return $response; |
| 323 |
} |
| 324 |
} |
| 325 |
|