['GET', 'POST'], 'callback' => [$this, 'handleAuthorize'], 'permission_callback' => '__return_true', ]); register_rest_route('asktam-mcp/v1', '/oauth/token', [ 'methods' => 'POST', 'callback' => [$this, 'handleToken'], 'permission_callback' => '__return_true', ]); register_rest_route('asktam-mcp/v1', '/oauth/metadata', [ 'methods' => 'GET', 'callback' => [$this, 'handleMetadata'], 'permission_callback' => '__return_true', ]); add_action('rest_api_init', function () { register_rest_route('', '/.well-known/oauth-authorization-server', [ 'methods' => 'GET', 'callback' => [$this, 'handleMetadata'], 'permission_callback' => '__return_true', ]); }, 20); } // ------------------------------------------------------------------------- // Authorization endpoint // ------------------------------------------------------------------------- public function handleAuthorize(\WP_REST_Request $request): void { $clientId = sanitize_text_field($request->get_param('client_id') ?? ''); $redirectUri = esc_url_raw($request->get_param('redirect_uri') ?? ''); $state = sanitize_text_field($request->get_param('state') ?? ''); $codeChallenge = sanitize_text_field($request->get_param('code_challenge') ?? ''); $challengeMethod = sanitize_text_field($request->get_param('code_challenge_method') ?? 'S256'); $approved = sanitize_text_field($request->get_param('_mcp_approve') ?? ''); // 1. Validate redirect_uri host if (!$this->isRedirectUriAllowed($redirectUri)) { wp_die('Invalid redirect_uri: host not in allowlist.', 'MCP Security Error', ['response' => 400]); } // 2. Validate client_id global $wpdb; $table = $wpdb->prefix . 'asktam_mcp_keys'; $row = $wpdb->get_row( $wpdb->prepare("SELECT * FROM {$table} WHERE api_key = %s AND is_active = 1", $clientId), ARRAY_A ); if (!$row) { $this->redirectError($redirectUri, 'invalid_client', $state); return; } // 3. PKCE: require code_challenge for authorization_code flow if (empty($codeChallenge)) { $this->redirectError($redirectUri, 'invalid_request', $state, 'PKCE code_challenge is required'); return; } // 4. POST: user approved if ($_SERVER['REQUEST_METHOD'] === 'POST' && $approved === '1') { $code = 'mcp_code_' . bin2hex(random_bytes(16)); set_transient('asktam_mcp_code_' . $code, [ 'client_id' => $clientId, 'redirect_uri' => $redirectUri, 'scopes' => $row['scopes'], 'key_id' => $row['id'], 'code_challenge' => $codeChallenge, 'challenge_method' => $challengeMethod, ], 300); // 5 min wp_redirect(add_query_arg(['code' => $code, 'state' => $state], $redirectUri)); exit; } // 5. GET: render approval page $this->renderApprovalPage($row, $clientId, $redirectUri, $state, $codeChallenge, $challengeMethod); } // ------------------------------------------------------------------------- // Token endpoint // ------------------------------------------------------------------------- public function handleToken(\WP_REST_Request $request): \WP_REST_Response { $grantType = $request->get_param('grant_type') ?? ''; return match ($grantType) { 'authorization_code' => $this->handleAuthCodeExchange($request), 'client_credentials' => $this->handleClientCredentials($request), default => $this->oauthError('unsupported_grant_type', 'Supported: authorization_code, client_credentials', 400), }; } private function handleAuthCodeExchange(\WP_REST_Request $request): \WP_REST_Response { $code = $request->get_param('code') ?? ''; $codeVerifier = $request->get_param('code_verifier') ?? ''; $redirectUri = $request->get_param('redirect_uri') ?? ''; if (empty($code)) { return $this->oauthError('invalid_request', 'Missing code', 400); } $codeData = get_transient('asktam_mcp_code_' . $code); if (!$codeData) { return $this->oauthError('invalid_grant', 'Authorization code expired or invalid', 400); } // Always delete — one-time use delete_transient('asktam_mcp_code_' . $code); // PKCE verification if (!empty($codeData['code_challenge'])) { if (empty($codeVerifier)) { return $this->oauthError('invalid_grant', 'PKCE code_verifier is required', 400); } $method = $codeData['challenge_method'] ?? 'S256'; $challenge = $codeData['code_challenge']; $valid = match ($method) { 'S256' => hash_equals($challenge, rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=')), 'plain' => hash_equals($challenge, $codeVerifier), default => false, }; if (!$valid) { return $this->oauthError('invalid_grant', 'PKCE verification failed', 400); } } // redirect_uri must match what was used in /authorize if (!empty($codeData['redirect_uri']) && $redirectUri !== $codeData['redirect_uri']) { return $this->oauthError('invalid_grant', 'redirect_uri mismatch', 400); } // Fetch key row and issue JWT global $wpdb; $row = $wpdb->get_row( $wpdb->prepare("SELECT * FROM {$wpdb->prefix}asktam_mcp_keys WHERE id = %d AND is_active = 1", $codeData['key_id']), ARRAY_A ); if (!$row) { return $this->oauthError('invalid_grant', 'Key no longer active', 400); } $accessToken = \AskTamMCP\Core\TokenManager::issue($row); return new \WP_REST_Response([ 'access_token' => $accessToken, 'token_type' => 'bearer', 'expires_in' => 3600, 'scope' => $row['scopes'], ], 200); } private function handleClientCredentials(\WP_REST_Request $request): \WP_REST_Response { [$clientId, $clientSecret] = $this->extractCredentials($request); if (empty($clientId) || empty($clientSecret)) { return $this->oauthError('invalid_client', 'Missing client_id or client_secret', 401); } global $wpdb; $row = $wpdb->get_row( $wpdb->prepare("SELECT * FROM {$wpdb->prefix}asktam_mcp_keys WHERE api_key = %s AND is_active = 1", $clientId), ARRAY_A ); if (!$row || !hash_equals($clientId, $clientSecret)) { return $this->oauthError('invalid_client', 'Invalid credentials', 401); } $accessToken = \AskTamMCP\Core\TokenManager::issue($row); return new \WP_REST_Response([ 'access_token' => $accessToken, 'token_type' => 'bearer', 'expires_in' => 3600, 'scope' => $row['scopes'], ], 200); } // ------------------------------------------------------------------------- // Metadata // ------------------------------------------------------------------------- public function handleMetadata(): \WP_REST_Response { $base = rest_url('asktam-mcp/v1'); return new \WP_REST_Response([ 'issuer' => get_site_url(), 'authorization_endpoint' => $base . '/oauth/authorize', 'token_endpoint' => $base . '/oauth/token', 'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic', 'none'], 'grant_types_supported' => ['authorization_code', 'client_credentials'], 'response_types_supported' => ['code'], 'code_challenge_methods_supported' => ['S256', 'plain'], 'scopes_supported' => ['read', 'write', 'admin', 'woocommerce'], ]); } // ------------------------------------------------------------------------- // Security helpers // ------------------------------------------------------------------------- /** * Validate redirect_uri against an allowlist of trusted hosts. * Only claude.ai and localhost are allowed by default. * Filterable for custom setups. */ private function isRedirectUriAllowed(string $uri): bool { if (empty($uri)) return false; $host = parse_url($uri, PHP_URL_HOST); if (!$host) return false; $allowed = apply_filters('asktam_mcp_allowed_redirect_hosts', self::ALLOWED_REDIRECT_HOSTS); foreach ($allowed as $allowedHost) { // Exact match or subdomain match if ($host === $allowedHost || str_ends_with($host, '.' . $allowedHost)) { return true; } } return false; } private function redirectError(string $redirectUri, string $error, string $state, string $description = ''): void { if ($redirectUri && $this->isRedirectUriAllowed($redirectUri)) { $params = ['error' => $error, 'state' => $state]; if ($description) $params['error_description'] = $description; wp_redirect(add_query_arg($params, $redirectUri)); exit; } status_header(400); header('Content-Type: text/plain'); echo "OAuth error: {$error}"; exit; } private function extractCredentials(\WP_REST_Request $request): array { $clientId = $request->get_param('client_id'); $clientSecret = $request->get_param('client_secret'); if (!empty($clientId) && !empty($clientSecret)) { return [$clientId, $clientSecret]; } $auth = $request->get_header('Authorization'); if ($auth && str_starts_with($auth, 'Basic ')) { $decoded = base64_decode(substr($auth, 6)); if ($decoded && str_contains($decoded, ':')) { [$clientId, $clientSecret] = explode(':', $decoded, 2); return [trim($clientId), trim($clientSecret)]; } } return [$clientId ?? '', '']; } private function oauthError(string $error, string $description, int $status): \WP_REST_Response { return new \WP_REST_Response(['error' => $error, 'error_description' => $description], $status); } // ------------------------------------------------------------------------- // Approval page UI // ------------------------------------------------------------------------- private function renderApprovalPage(array $row, string $clientId, string $redirectUri, string $state, string $codeChallenge, string $challengeMethod): void { $siteName = get_bloginfo('name'); $keyName = esc_html($row['name']); $scopes = array_map('trim', explode(',', $row['scopes'])); $actionUrl = rest_url('asktam-mcp/v1/oauth/authorize'); while (ob_get_level()) ob_end_clean(); status_header(200); header('Content-Type: text/html; charset=UTF-8'); header('X-Robots-Tag: noindex'); ?>
An AI client is requesting access to your WordPress site using this API key: