diff --git a/includes/MCP/OAuthServer.php b/includes/MCP/OAuthServer.php index ceb2bc1..41244dc 100644 --- a/includes/MCP/OAuthServer.php +++ b/includes/MCP/OAuthServer.php @@ -6,10 +6,17 @@ defined('ABSPATH') || exit; /** * OAuth 2.0 server with: + * - Dynamic Client Registration (RFC 7591) for MCP clients (Claude.ai) * - PKCE (S256) verification on token exchange - * - redirect_uri allowlist per key + * - redirect_uri allowlist per key + per registered client * - Short-lived JWT access tokens (not raw API keys) * - authorization_code + client_credentials grants + * + * Security note on DCR: registered client_ids use the 'mcpc_' prefix so they are + * NOT accepted as raw bearer tokens by TokenManager::resolve() (which only accepts + * 'mcp_'-prefixed raw keys). A registered client therefore cannot act as a credential + * on its own — the only usable token is the short-lived JWT, which is issued only + * after the site owner approves on the consent screen. */ class OAuthServer { @@ -20,7 +27,15 @@ class OAuthServer { '127.0.0.1', ]; + private const CLIENTS_OPTION = 'asktam_mcp_oauth_clients'; + public function registerRoutes(): void { + register_rest_route('asktam-mcp/v1', '/oauth/register', [ + 'methods' => 'POST', + 'callback' => [$this, 'handleRegister'], + 'permission_callback' => '__return_true', + ]); + register_rest_route('asktam-mcp/v1', '/oauth/authorize', [ 'methods' => ['GET', 'POST'], 'callback' => [$this, 'handleAuthorize'], @@ -48,6 +63,70 @@ class OAuthServer { }, 20); } + // ------------------------------------------------------------------------- + // Dynamic Client Registration (RFC 7591) + // ------------------------------------------------------------------------- + public function handleRegister(\WP_REST_Request $request): \WP_REST_Response { + $body = $request->get_json_params(); + if (!is_array($body) || empty($body)) { + $body = $request->get_params(); + } + + $redirectUris = $body['redirect_uris'] ?? []; + if (is_string($redirectUris)) $redirectUris = [$redirectUris]; + if (empty($redirectUris) || !is_array($redirectUris)) { + return $this->oauthError('invalid_redirect_uri', 'redirect_uris is required', 400); + } + + $clean = []; + foreach ($redirectUris as $uri) { + $uri = esc_url_raw((string) $uri); + if (!$uri || !$this->isRedirectUriAllowed($uri)) { + return $this->oauthError('invalid_redirect_uri', 'redirect_uri host not allowed: ' . $uri, 400); + } + $clean[] = $uri; + } + + $clientName = sanitize_text_field($body['client_name'] ?? 'MCP Client'); + if ($clientName === '') $clientName = 'MCP Client'; + + // DCR clients are public (PKCE); we do not issue a client_secret. + $scopes = 'read,write,admin,woocommerce'; + $clientId = 'mcpc_' . bin2hex(random_bytes(24)); + + global $wpdb; + $table = $wpdb->prefix . 'asktam_mcp_keys'; + $wpdb->insert($table, [ + 'name' => 'oauth: ' . $clientName, + 'api_key' => $clientId, + 'scopes' => $scopes, + 'created_at' => current_time('mysql'), + 'is_active' => 1, + ]); + + // Store the registered redirect_uris so /authorize can enforce an exact match. + $reg = get_option(self::CLIENTS_OPTION, []); + if (!is_array($reg)) $reg = []; + $reg[$clientId] = [ + 'client_name' => $clientName, + 'redirect_uris' => $clean, + 'created_at' => time(), + ]; + if (count($reg) > 50) $reg = array_slice($reg, -50, 50, true); // bound growth + update_option(self::CLIENTS_OPTION, $reg, false); + + return new \WP_REST_Response([ + 'client_id' => $clientId, + 'client_id_issued_at' => time(), + 'redirect_uris' => $clean, + 'grant_types' => ['authorization_code'], + 'response_types' => ['code'], + 'token_endpoint_auth_method' => 'none', + 'client_name' => $clientName, + 'scope' => str_replace(',', ' ', $scopes), + ], 201); + } + // ------------------------------------------------------------------------- // Authorization endpoint // ------------------------------------------------------------------------- @@ -64,6 +143,16 @@ class OAuthServer { wp_die('Invalid redirect_uri: host not in allowlist.', 'MCP Security Error', ['response' => 400]); } + // 1b. If this client_id was issued via DCR, enforce its registered redirect_uris exactly. + $reg = get_option(self::CLIENTS_OPTION, []); + if (is_array($reg) && isset($reg[$clientId])) { + $registered = $reg[$clientId]['redirect_uris'] ?? []; + if (!empty($registered) && !in_array($redirectUri, $registered, true)) { + $this->redirectError($redirectUri, 'invalid_request', $state, 'redirect_uri not registered for this client'); + return; + } + } + // 2. Validate client_id global $wpdb; $table = $wpdb->prefix . 'asktam_mcp_keys'; @@ -215,6 +304,7 @@ class OAuthServer { 'issuer' => get_site_url(), 'authorization_endpoint' => $base . '/oauth/authorize', 'token_endpoint' => $base . '/oauth/token', + 'registration_endpoint' => $base . '/oauth/register', 'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic', 'none'], 'grant_types_supported' => ['authorization_code', 'client_credentials'], 'response_types_supported' => ['code'],